Files
k-ap/src/tests/activities.rs
Gabriel Kaszewski 7171a1791a feat: actor cache TTL with staleness-aware re-fetch
Adds fetched_at to RemoteActor, configurable TTL via builder
(.actor_cache_ttl_secs, default 24h), and get_or_refresh_remote_actor
helper that re-fetches stale actors from origin.

Closes #3
2026-05-30 02:46:54 +02:00

1215 lines
38 KiB
Rust

/// Business-logic tests for activity receive() implementations.
///
/// These tests exercise each activity handler with in-memory stubs,
/// verifying the correct callbacks fire and the correct repo mutations happen.
/// Activities that require outbound HTTP (Follow → dereference actor) are tested
/// only for their early-return paths; the happy path requires a real HTTP stack.
use std::collections::HashSet;
use std::sync::Arc;
use activitypub_federation::{config::FederationConfig, fetch::object_id::ObjectId};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tokio::sync::Mutex;
use url::Url;
use crate::activities::{
AcceptActivity, AddActivity, AnnounceActivity, AnnounceType, BlockActivity, BlockType,
CreateActivity, DeleteActivity, FollowActivity, LikeActivity, LikeType, RejectActivity,
UndoActivity, UpdateActivity,
};
use crate::content::{ApContentReader, ApObjectHandler};
use crate::data::FederationData;
use crate::repository::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use crate::user::{ApActorType, ApUser, ApUserRepository};
// ── Stubs ─────────────────────────────────────────────────────────────────────
#[derive(Default)]
struct MemActivityRepo {
processed: Mutex<HashSet<String>>,
}
#[async_trait]
impl ActivityRepository for MemActivityRepo {
async fn is_activity_processed(&self, id: &str) -> anyhow::Result<bool> {
Ok(self.processed.lock().await.contains(id))
}
async fn mark_activity_processed(&self, id: &str) -> anyhow::Result<()> {
self.processed.lock().await.insert(id.to_string());
Ok(())
}
}
/// Tracking follow repo — records every mutating call for assertion.
#[derive(Default)]
struct MemFollowRepo {
// recorded mutations
added_followers: Mutex<Vec<(uuid::Uuid, String, FollowerStatus)>>,
removed_followers: Mutex<Vec<(uuid::Uuid, String)>>,
removed_following: Mutex<Vec<(uuid::Uuid, String)>>,
following_status_updates: Mutex<Vec<(uuid::Uuid, String, FollowingStatus)>>,
}
#[async_trait]
impl FollowRepository for MemFollowRepo {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
_: &str,
) -> anyhow::Result<()> {
self.added_followers.lock().await.push((
local_user_id,
remote_actor_url.to_string(),
status,
));
Ok(())
}
async fn get_follower_follow_activity_id(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.removed_followers
.lock()
.await
.push((local_user_id, remote_actor_url.to_string()));
Ok(())
}
async fn get_followers(&self, _: uuid::Uuid) -> anyhow::Result<Vec<Follower>> {
Ok(vec![])
}
async fn get_followers_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<Follower>> {
Ok(vec![])
}
async fn count_followers(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn update_follower_status(
&self,
_: uuid::Uuid,
_: &str,
_: FollowerStatus,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_pending_followers(&self, _: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_accepted_followers_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_following(&self, _: uuid::Uuid, _: RemoteActor, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_follow_activity_id(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn remove_following(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<()> {
self.removed_following
.lock()
.await
.push((local_user_id, actor_url.to_string()));
Ok(())
}
async fn get_following(&self, _: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_following_page(
&self,
_: uuid::Uuid,
_: u32,
_: usize,
) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> anyhow::Result<()> {
self.following_status_updates.lock().await.push((
local_user_id,
remote_actor_url.to_string(),
status,
));
Ok(())
}
async fn get_following_outbox_url(
&self,
_: uuid::Uuid,
_: &str,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result<Vec<uuid::Uuid>> {
Ok(vec![])
}
}
/// Tracking actor repo.
#[derive(Default)]
struct MemActorRepo {
added_announces: Mutex<Vec<String>>,
removed_announces: Mutex<Vec<String>>,
}
#[async_trait]
impl ActorRepository for MemActorRepo {
async fn get_local_actor_keypair(
&self,
_: uuid::Uuid,
) -> anyhow::Result<Option<(String, String)>> {
Ok(None)
}
async fn save_local_actor_keypair(
&self,
_: uuid::Uuid,
_: String,
_: String,
) -> anyhow::Result<()> {
Ok(())
}
async fn upsert_remote_actor(&self, _: RemoteActor) -> anyhow::Result<()> {
Ok(())
}
async fn get_remote_actor(&self, _: &str) -> anyhow::Result<Option<RemoteActor>> {
Ok(None)
}
async fn add_announce(
&self,
activity_id: &str,
_: &str,
_: &str,
_: DateTime<Utc>,
) -> anyhow::Result<()> {
self.added_announces
.lock()
.await
.push(activity_id.to_string());
Ok(())
}
async fn remove_announce(&self, activity_id: &str, _: &str) -> anyhow::Result<()> {
self.removed_announces
.lock()
.await
.push(activity_id.to_string());
Ok(())
}
async fn count_announces(&self, _: &str) -> anyhow::Result<usize> {
Ok(0)
}
}
struct MemBlocklistRepo {
blocked_domains: HashSet<String>,
blocked_actors: HashSet<(uuid::Uuid, String)>,
}
impl MemBlocklistRepo {
fn blocking_domain(domain: &str) -> Self {
let mut blocked_domains = HashSet::new();
blocked_domains.insert(domain.to_string());
Self {
blocked_domains,
blocked_actors: HashSet::new(),
}
}
fn blocking_actor(local_user_id: uuid::Uuid, actor_url: &str) -> Self {
let mut blocked_actors = HashSet::new();
blocked_actors.insert((local_user_id, actor_url.to_string()));
Self {
blocked_domains: HashSet::new(),
blocked_actors,
}
}
}
impl Default for MemBlocklistRepo {
fn default() -> Self {
Self {
blocked_domains: HashSet::new(),
blocked_actors: HashSet::new(),
}
}
}
#[async_trait]
impl BlocklistRepository for MemBlocklistRepo {
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result<bool> {
Ok(self.blocked_domains.contains(domain))
}
async fn add_blocked_actor(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_actor(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_actors(&self, _: uuid::Uuid) -> anyhow::Result<Vec<String>> {
Ok(vec![])
}
async fn is_actor_blocked(
&self,
local_user_id: uuid::Uuid,
actor_url: &str,
) -> anyhow::Result<bool> {
Ok(self
.blocked_actors
.contains(&(local_user_id, actor_url.to_string())))
}
}
struct MemUserRepo {
user_id: uuid::Uuid,
username: String,
}
impl MemUserRepo {
fn new(user_id: uuid::Uuid, username: &str) -> Self {
Self {
user_id,
username: username.to_string(),
}
}
}
#[async_trait]
impl ApUserRepository for MemUserRepo {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
if id == self.user_id {
Ok(Some(ApUser {
id,
username: self.username.clone(),
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: vec![],
profile_url: None,
attachment: vec![],
manually_approves_followers: true,
discoverable: true,
actor_type: ApActorType::Person,
featured_url: None,
}))
} else {
Ok(None)
}
}
async fn find_by_username(&self, _: &str) -> anyhow::Result<Option<ApUser>> {
Ok(None)
}
async fn count_users(&self) -> anyhow::Result<usize> {
Ok(1)
}
}
/// Tracking object handler — records every callback for assertion.
#[derive(Default)]
struct MemHandler {
creates: Mutex<Vec<(Url, Url)>>, // (ap_id, actor_url)
updates: Mutex<Vec<(Url, Url)>>,
deletes: Mutex<Vec<(Url, Url)>>,
actors_removed: Mutex<Vec<Url>>,
likes: Mutex<Vec<(Url, Url)>>,
unlikes: Mutex<Vec<(Url, Url)>>,
announces_received: Mutex<Vec<(Url, Url)>>,
announces_removed: Mutex<Vec<(Url, Url)>>,
announces_of_remote: Mutex<Vec<(Url, Url)>>,
mentions: Mutex<Vec<(Url, uuid::Uuid)>>,
}
#[async_trait]
impl ApObjectHandler for MemHandler {
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
_: serde_json::Value,
) -> anyhow::Result<()> {
self.creates
.lock()
.await
.push((ap_id.clone(), actor_url.clone()));
Ok(())
}
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
_: serde_json::Value,
) -> anyhow::Result<()> {
self.updates
.lock()
.await
.push((ap_id.clone(), actor_url.clone()));
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.deletes
.lock()
.await
.push((ap_id.clone(), actor_url.clone()));
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.actors_removed.lock().await.push(actor_url.clone());
Ok(())
}
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.likes
.lock()
.await
.push((object_url.clone(), actor_url.clone()));
Ok(())
}
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.unlikes
.lock()
.await
.push((object_url.clone(), actor_url.clone()));
Ok(())
}
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.announces_received
.lock()
.await
.push((object_url.clone(), actor_url.clone()));
Ok(())
}
async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.announces_removed
.lock()
.await
.push((object_url.clone(), actor_url.clone()));
Ok(())
}
async fn on_announce_of_remote(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.announces_of_remote
.lock()
.await
.push((object_url.clone(), actor_url.clone()));
Ok(())
}
async fn on_mention(&self, ap_id: &Url, user_id: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
self.mentions.lock().await.push((ap_id.clone(), user_id));
Ok(())
}
}
#[derive(Default)]
struct MemContentReader;
#[async_trait]
impl ApContentReader for MemContentReader {
async fn get_local_objects_page(
&self,
_: uuid::Uuid,
_: Option<DateTime<Utc>>,
_: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
Ok(vec![])
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
}
// ── Test helpers ──────────────────────────────────────────────────────────────
const LOCAL_DOMAIN: &str = "example.com";
const BASE_URL: &str = "https://example.com";
const REMOTE_ACTOR: &str = "https://remote.example/users/bob";
fn local_user_id() -> uuid::Uuid {
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, b"alice")
}
fn local_actor_url() -> Url {
format!("{}/users/{}", BASE_URL, local_user_id())
.parse()
.unwrap()
}
fn remote_actor_url() -> Url {
REMOTE_ACTOR.parse().unwrap()
}
fn activity_url(path: &str) -> Url {
format!("https://remote.example{}", path).parse().unwrap()
}
fn local_note_url() -> Url {
format!("{}/notes/1", BASE_URL).parse().unwrap()
}
fn remote_note_url() -> Url {
"https://other.example/notes/99".parse().unwrap()
}
struct TestSetup {
follow_repo: Arc<MemFollowRepo>,
actor_repo: Arc<MemActorRepo>,
handler: Arc<MemHandler>,
config: FederationConfig<FederationData>,
}
async fn setup(blocklist: MemBlocklistRepo, local_user_id: uuid::Uuid) -> TestSetup {
let follow_repo = Arc::new(MemFollowRepo::default());
let actor_repo = Arc::new(MemActorRepo::default());
let handler = Arc::new(MemHandler::default());
let data = FederationData::new(
Arc::new(MemActivityRepo::default()),
follow_repo.clone(),
actor_repo.clone(),
Arc::new(blocklist),
Arc::new(MemUserRepo::new(local_user_id, "alice")),
Arc::new(MemContentReader),
handler.clone(),
BASE_URL.to_string(),
false,
"test".to_string(),
None,
std::time::Duration::from_secs(24 * 60 * 60),
);
let config = FederationConfig::builder()
.domain(LOCAL_DOMAIN)
.app_data(data)
.debug(true)
.build()
.await
.unwrap();
TestSetup {
follow_repo,
actor_repo,
handler,
config,
}
}
// ── AcceptActivity tests ───────────────────────────────────────────────────────
#[tokio::test]
async fn accept_updates_following_status_to_accepted() {
use activitypub_federation::kinds::activity::AcceptType;
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
// AP Accept flow:
// Our local actor sent Follow(remote_actor).
// Remote actor responds with Accept(Follow(...)).
//
// FollowActivity.actor = our local actor (who sent the Follow)
// FollowActivity.object = the remote actor we followed
// AcceptActivity.actor = the remote actor (who accepted)
// AcceptActivity.object = the original FollowActivity
//
// AcceptActivity::receive extracts local_user_id from FollowActivity.actor,
// so that URL must be UUID-based (our standard actor URL format).
let follow = FollowActivity {
id: activity_url("/follow/1"),
kind: Default::default(),
actor: ObjectId::from(local_actor_url()), // OUR actor sent the Follow
object: ObjectId::from(remote_actor_url()), // we followed remote
};
let accept = AcceptActivity {
id: activity_url("/accept/1"),
kind: AcceptType::default(),
actor: ObjectId::from(remote_actor_url()), // remote accepts
object: follow,
};
use activitypub_federation::traits::Activity;
accept.receive(&data).await.unwrap();
let updates = s.follow_repo.following_status_updates.lock().await;
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].0, local_id);
assert_eq!(updates[0].1, REMOTE_ACTOR);
assert!(matches!(updates[0].2, FollowingStatus::Accepted));
}
// ── RejectActivity tests ───────────────────────────────────────────────────────
#[tokio::test]
async fn reject_removes_following() {
use activitypub_federation::kinds::activity::RejectType;
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
// Same actor structure as Accept: local sent Follow(remote), remote rejects.
// RejectActivity::receive extracts local_user_id from FollowActivity.actor.
let follow = FollowActivity {
id: activity_url("/follow/1"),
kind: Default::default(),
actor: ObjectId::from(local_actor_url()), // OUR actor sent the Follow
object: ObjectId::from(remote_actor_url()), // we followed remote
};
let reject = RejectActivity {
id: activity_url("/reject/1"),
kind: RejectType::default(),
actor: ObjectId::from(remote_actor_url()),
object: follow,
};
use activitypub_federation::traits::Activity;
reject.receive(&data).await.unwrap();
let removed = s.follow_repo.removed_following.lock().await;
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].0, local_id);
assert_eq!(removed[0].1, REMOTE_ACTOR);
}
// ── UndoActivity tests ────────────────────────────────────────────────────────
#[tokio::test]
async fn undo_follow_removes_follower_and_cleans_content() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let undo = UndoActivity {
id: activity_url("/undo/1"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Follow",
"id": "https://remote.example/follow/1",
"actor": REMOTE_ACTOR,
"object": local_actor_url().as_str(),
}),
};
use activitypub_federation::traits::Activity;
undo.receive(&data).await.unwrap();
let removed = s.follow_repo.removed_followers.lock().await;
assert_eq!(removed.len(), 1, "follower should be removed");
assert_eq!(removed[0].0, local_id);
let cleaned = s.handler.actors_removed.lock().await;
assert_eq!(cleaned.len(), 1, "on_actor_removed should be called");
assert_eq!(cleaned[0], remote_actor_url());
}
#[tokio::test]
async fn undo_like_calls_on_unlike_for_local_object() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let undo = UndoActivity {
id: activity_url("/undo/2"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Like",
"id": "https://remote.example/like/1",
"actor": REMOTE_ACTOR,
"object": local_note_url().as_str(),
}),
};
use activitypub_federation::traits::Activity;
undo.receive(&data).await.unwrap();
let unlikes = s.handler.unlikes.lock().await;
assert_eq!(unlikes.len(), 1);
assert_eq!(unlikes[0].0, local_note_url());
}
#[tokio::test]
async fn undo_like_ignores_remote_object() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let undo = UndoActivity {
id: activity_url("/undo/3"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Like",
"id": "https://remote.example/like/2",
"actor": REMOTE_ACTOR,
"object": remote_note_url().as_str(), // NOT local
}),
};
use activitypub_federation::traits::Activity;
undo.receive(&data).await.unwrap();
let unlikes = s.handler.unlikes.lock().await;
assert!(
unlikes.is_empty(),
"remote object Like should not trigger on_unlike"
);
}
#[tokio::test]
async fn undo_announce_removes_record_and_notifies() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let undo = UndoActivity {
id: activity_url("/undo/4"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Announce",
"id": "https://remote.example/announce/1",
"actor": REMOTE_ACTOR,
"object": local_note_url().as_str(),
}),
};
use activitypub_federation::traits::Activity;
undo.receive(&data).await.unwrap();
let removed = s.actor_repo.removed_announces.lock().await;
assert_eq!(removed.len(), 1);
assert_eq!(removed[0], "https://remote.example/announce/1");
let notified = s.handler.announces_removed.lock().await;
assert_eq!(notified.len(), 1);
assert_eq!(notified[0].0, local_note_url());
}
#[tokio::test]
async fn undo_announce_ignores_remote_object() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let undo = UndoActivity {
id: activity_url("/undo/5"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Announce",
"id": "https://remote.example/announce/2",
"actor": REMOTE_ACTOR,
"object": remote_note_url().as_str(), // NOT local
}),
};
use activitypub_federation::traits::Activity;
undo.receive(&data).await.unwrap();
// remove_announce should still be called (clean up the record)
let removed = s.actor_repo.removed_announces.lock().await;
assert_eq!(
removed.len(),
1,
"announce record should be removed regardless"
);
// but on_announce_removed should NOT fire for non-local objects
let notified = s.handler.announces_removed.lock().await;
assert!(
notified.is_empty(),
"on_announce_removed should not fire for remote-hosted objects"
);
}
// ── CreateActivity tests ───────────────────────────────────────────────────────
#[tokio::test]
async fn create_uses_object_id_not_activity_id() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let object_id = "https://remote.example/notes/42";
let create = CreateActivity {
id: activity_url("/create/99"), // activity id — should NOT be used
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Note",
"id": object_id,
"content": "Hello world",
"attributedTo": REMOTE_ACTOR,
}),
to: vec![],
cc: vec![],
bto: vec![],
bcc: vec![],
};
use activitypub_federation::traits::Activity;
create.receive(&data).await.unwrap();
let creates = s.handler.creates.lock().await;
assert_eq!(creates.len(), 1);
assert_eq!(
creates[0].0.as_str(),
object_id,
"on_create should receive the OBJECT id, not the Create activity id"
);
}
#[tokio::test]
async fn create_with_mention_fires_on_mention() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let note_id = "https://remote.example/notes/mention-test";
let local_user_url = local_actor_url();
let create = CreateActivity {
id: activity_url("/create/mention"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Note",
"id": note_id,
"content": "Hey @alice!",
"attributedTo": REMOTE_ACTOR,
"tag": [{"type": "Mention", "href": local_user_url.as_str()}],
}),
to: vec![],
cc: vec![],
bto: vec![],
bcc: vec![],
};
use activitypub_federation::traits::Activity;
create.receive(&data).await.unwrap();
let mentions = s.handler.mentions.lock().await;
assert_eq!(
mentions.len(),
1,
"on_mention should fire for the local user"
);
assert_eq!(mentions[0].1, local_id);
// on_create should ALSO fire (mention doesn't replace content delivery)
let creates = s.handler.creates.lock().await;
assert_eq!(creates.len(), 1);
}
// ── UpdateActivity tests ───────────────────────────────────────────────────────
#[tokio::test]
async fn update_uses_object_id() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let object_id = "https://remote.example/notes/42";
let update = UpdateActivity {
id: activity_url("/update/1"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({"type": "Note", "id": object_id, "content": "Edited"}),
to: vec![],
cc: vec![],
};
use activitypub_federation::traits::Activity;
update.receive(&data).await.unwrap();
let updates = s.handler.updates.lock().await;
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].0.as_str(), object_id);
}
// ── DeleteActivity tests ───────────────────────────────────────────────────────
#[tokio::test]
async fn delete_object_calls_on_delete() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let note_id = "https://remote.example/notes/to-delete";
let delete = DeleteActivity {
id: activity_url("/delete/1"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({"type": "Tombstone", "id": note_id}),
to: vec![],
cc: vec![],
};
use activitypub_federation::traits::Activity;
delete.receive(&data).await.unwrap();
let deletes = s.handler.deletes.lock().await;
assert_eq!(deletes.len(), 1);
assert_eq!(deletes[0].0.as_str(), note_id);
let actor_removed = s.handler.actors_removed.lock().await;
assert!(
actor_removed.is_empty(),
"on_actor_removed should NOT fire for note deletion"
);
}
#[tokio::test]
async fn delete_actor_calls_on_actor_removed() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
// AP actor self-deletion: object URL == actor URL
let delete = DeleteActivity {
id: activity_url("/delete/actor"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!(REMOTE_ACTOR), // plain URL string
to: vec![],
cc: vec![],
};
use activitypub_federation::traits::Activity;
delete.receive(&data).await.unwrap();
let actor_removed = s.handler.actors_removed.lock().await;
assert_eq!(actor_removed.len(), 1);
assert_eq!(actor_removed[0], remote_actor_url());
let deletes = s.handler.deletes.lock().await;
assert!(
deletes.is_empty(),
"on_delete should NOT fire for actor deletion"
);
}
// ── AnnounceActivity tests ────────────────────────────────────────────────────
#[tokio::test]
async fn announce_local_object_records_and_notifies() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let announce = AnnounceActivity {
id: activity_url("/announce/1"),
kind: AnnounceType,
actor: ObjectId::from(remote_actor_url()),
object: local_note_url(),
published: None,
to: vec![],
cc: vec![],
};
use activitypub_federation::traits::Activity;
announce.receive(&data).await.unwrap();
let added = s.actor_repo.added_announces.lock().await;
assert_eq!(added.len(), 1, "announce record should be created");
let notified = s.handler.announces_received.lock().await;
assert_eq!(notified.len(), 1);
assert_eq!(notified[0].0, local_note_url());
let remote = s.handler.announces_of_remote.lock().await;
assert!(
remote.is_empty(),
"on_announce_of_remote should NOT fire for local objects"
);
}
#[tokio::test]
async fn announce_remote_object_calls_on_announce_of_remote() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let announce = AnnounceActivity {
id: activity_url("/announce/2"),
kind: AnnounceType,
actor: ObjectId::from(remote_actor_url()),
object: remote_note_url(), // NOT local
published: None,
to: vec![],
cc: vec![],
};
use activitypub_federation::traits::Activity;
announce.receive(&data).await.unwrap();
let remote = s.handler.announces_of_remote.lock().await;
assert_eq!(remote.len(), 1);
assert_eq!(remote[0].0, remote_note_url());
let local = s.handler.announces_received.lock().await;
assert!(
local.is_empty(),
"on_announce_received should NOT fire for remote objects"
);
let added = s.actor_repo.added_announces.lock().await;
assert!(
added.is_empty(),
"announce record should NOT be created for remote objects"
);
}
// ── LikeActivity tests ────────────────────────────────────────────────────────
#[tokio::test]
async fn like_local_object_calls_on_like() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let like = LikeActivity {
id: activity_url("/like/1"),
kind: LikeType,
actor: ObjectId::from(remote_actor_url()),
object: local_note_url(),
};
use activitypub_federation::traits::Activity;
like.receive(&data).await.unwrap();
let likes = s.handler.likes.lock().await;
assert_eq!(likes.len(), 1);
assert_eq!(likes[0].0, local_note_url());
}
#[tokio::test]
async fn like_remote_object_is_ignored() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let like = LikeActivity {
id: activity_url("/like/2"),
kind: LikeType,
actor: ObjectId::from(remote_actor_url()),
object: remote_note_url(), // NOT local
};
use activitypub_federation::traits::Activity;
like.receive(&data).await.unwrap();
let likes = s.handler.likes.lock().await;
assert!(
likes.is_empty(),
"remote object Like should be silently ignored"
);
}
// ── AddActivity tests ─────────────────────────────────────────────────────────
#[tokio::test]
async fn add_uses_object_id_not_activity_id() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let object_id = "https://remote.example/watchlist/item/5";
let add = AddActivity {
id: activity_url("/add/99"), // activity id — should NOT be used
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({
"type": "Movie",
"id": object_id,
"name": "Some Film",
"attributedTo": REMOTE_ACTOR,
}),
to: vec![],
cc: vec![],
};
use activitypub_federation::traits::Activity;
add.receive(&data).await.unwrap();
let creates = s.handler.creates.lock().await;
assert_eq!(creates.len(), 1);
assert_eq!(
creates[0].0.as_str(),
object_id,
"on_create should use the OBJECT id, not the Add activity id"
);
}
// ── BlockActivity tests ───────────────────────────────────────────────────────
#[tokio::test]
async fn block_removes_follow_relationships() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let block = BlockActivity {
id: activity_url("/block/1"),
kind: BlockType,
actor: ObjectId::from(remote_actor_url()),
object: local_actor_url(),
};
use activitypub_federation::traits::Activity;
block.receive(&data).await.unwrap();
let removed_following = s.follow_repo.removed_following.lock().await;
assert_eq!(
removed_following.len(),
1,
"following should be removed on Block"
);
assert_eq!(removed_following[0].0, local_id);
let removed_followers = s.follow_repo.removed_followers.lock().await;
assert_eq!(
removed_followers.len(),
1,
"follower should be removed on Block"
);
assert_eq!(removed_followers[0].0, local_id);
}
// ── Domain / actor blocking ───────────────────────────────────────────────────
#[tokio::test]
async fn activity_from_blocked_domain_is_skipped() {
let local_id = local_user_id();
let s = setup(
MemBlocklistRepo::blocking_domain("remote.example"),
local_id,
)
.await;
let data = s.config.to_request_data();
let create = CreateActivity {
id: activity_url("/create/blocked"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({"type": "Note", "id": "https://remote.example/notes/1"}),
to: vec![],
cc: vec![],
bto: vec![],
bcc: vec![],
};
use activitypub_federation::traits::Activity;
create.receive(&data).await.unwrap();
let creates = s.handler.creates.lock().await;
assert!(
creates.is_empty(),
"activity from blocked domain must be skipped"
);
}
#[tokio::test]
async fn follow_from_blocked_actor_is_skipped_before_http() {
// Verifies the SSRF fix: actor block checked BEFORE any HTTP dereference.
let local_id = local_user_id();
let s = setup(
MemBlocklistRepo::blocking_actor(local_id, REMOTE_ACTOR),
local_id,
)
.await;
let data = s.config.to_request_data();
let follow = FollowActivity {
id: activity_url("/follow/blocked"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: ObjectId::from(local_actor_url()),
};
use activitypub_federation::traits::Activity;
// In debug mode, dereference would be attempted if we got past the block check.
// The test passes only if we return early before any HTTP call.
follow.receive(&data).await.unwrap();
let added = s.follow_repo.added_followers.lock().await;
assert!(
added.is_empty(),
"blocked actor follow must be silently discarded"
);
}
// ── Idempotency ───────────────────────────────────────────────────────────────
#[tokio::test]
async fn duplicate_activity_id_is_skipped() {
let local_id = local_user_id();
let s = setup(MemBlocklistRepo::default(), local_id).await;
let data = s.config.to_request_data();
let make_create = || CreateActivity {
id: activity_url("/create/dedup"),
kind: Default::default(),
actor: ObjectId::from(remote_actor_url()),
object: serde_json::json!({"type": "Note", "id": "https://remote.example/notes/dedup"}),
to: vec![],
cc: vec![],
bto: vec![],
bcc: vec![],
};
use activitypub_federation::traits::Activity;
make_create().receive(&data).await.unwrap();
make_create().receive(&data).await.unwrap(); // duplicate
let creates = s.handler.creates.lock().await;
assert_eq!(creates.len(), 1, "duplicate delivery must be deduplicated");
}