diff --git a/src/lib.rs b/src/lib.rs index d3afe5d..5e5db8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,3 +34,11 @@ pub use user::{ #[cfg(test)] #[path = "tests/integration.rs"] mod integration_tests; + +#[cfg(test)] +#[path = "tests/activities.rs"] +mod activity_tests; + +#[cfg(test)] +#[path = "tests/broadcast.rs"] +mod broadcast_tests; diff --git a/src/service/broadcast.rs b/src/service/broadcast.rs index d1ea041..34ae190 100644 --- a/src/service/broadcast.rs +++ b/src/service/broadcast.rs @@ -419,7 +419,7 @@ impl ActivityPubService { /// Returns `(to, cc)` addressing for the given visibility. /// `Private` is handled before calling this (early return in broadcast methods). -pub(super) fn visibility_addressing( +pub(crate) fn visibility_addressing( visibility: ApVisibility, followers_url: &Url, ) -> (Vec, Vec) { diff --git a/src/service/mod.rs b/src/service/mod.rs index 10f2cfb..6a8aaaa 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -23,7 +23,7 @@ use crate::{ }; mod backfill; -mod broadcast; +pub(crate) mod broadcast; pub(super) mod delivery; mod follow; diff --git a/src/tests/activities.rs b/src/tests/activities.rs new file mode 100644 index 0000000..e6c6599 --- /dev/null +++ b/src/tests/activities.rs @@ -0,0 +1,1213 @@ +/// 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>, +} + +#[async_trait] +impl ActivityRepository for MemActivityRepo { + async fn is_activity_processed(&self, id: &str) -> anyhow::Result { + 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>, + removed_followers: Mutex>, + removed_following: Mutex>, + following_status_updates: Mutex>, +} + +#[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> { + 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> { + Ok(vec![]) + } + async fn get_followers_page( + &self, + _: uuid::Uuid, + _: u32, + _: usize, + ) -> anyhow::Result> { + Ok(vec![]) + } + async fn count_followers(&self, _: uuid::Uuid) -> anyhow::Result { + 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> { + Ok(vec![]) + } + async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result> { + Ok(vec![]) + } + async fn count_accepted_followers(&self, _: uuid::Uuid) -> anyhow::Result { + Ok(0) + } + async fn get_accepted_followers_page( + &self, + _: uuid::Uuid, + _: u32, + _: usize, + ) -> anyhow::Result> { + 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> { + 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> { + Ok(vec![]) + } + async fn get_following_page( + &self, + _: uuid::Uuid, + _: u32, + _: usize, + ) -> anyhow::Result> { + Ok(vec![]) + } + async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result { + 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> { + Ok(None) + } + async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result> { + Ok(vec![]) + } +} + +/// Tracking actor repo. +#[derive(Default)] +struct MemActorRepo { + added_announces: Mutex>, + removed_announces: Mutex>, +} + +#[async_trait] +impl ActorRepository for MemActorRepo { + async fn get_local_actor_keypair( + &self, + _: uuid::Uuid, + ) -> anyhow::Result> { + 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> { + Ok(None) + } + async fn add_announce( + &self, + activity_id: &str, + _: &str, + _: &str, + _: DateTime, + ) -> 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 { + Ok(0) + } +} + +struct MemBlocklistRepo { + blocked_domains: HashSet, + 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> { + Ok(vec![]) + } + async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result { + 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> { + Ok(vec![]) + } + async fn is_actor_blocked( + &self, + local_user_id: uuid::Uuid, + actor_url: &str, + ) -> anyhow::Result { + 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> { + 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> { + Ok(None) + } + async fn count_users(&self) -> anyhow::Result { + Ok(1) + } +} + +/// Tracking object handler — records every callback for assertion. +#[derive(Default)] +struct MemHandler { + creates: Mutex>, // (ap_id, actor_url) + updates: Mutex>, + deletes: Mutex>, + actors_removed: Mutex>, + likes: Mutex>, + unlikes: Mutex>, + announces_received: Mutex>, + announces_removed: Mutex>, + announces_of_remote: Mutex>, + mentions: Mutex>, +} + +#[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>, + _: usize, + ) -> anyhow::Result)>> { + Ok(vec![]) + } + async fn count_local_posts(&self) -> anyhow::Result { + 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, + actor_repo: Arc, + handler: Arc, + config: FederationConfig, +} + +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, + ); + + 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"); +} diff --git a/src/tests/actors.rs b/src/tests/actors.rs index 7b95828..8575d0d 100644 --- a/src/tests/actors.rs +++ b/src/tests/actors.rs @@ -1,5 +1,7 @@ use super::*; +// ── Person AP JSON serialization ────────────────────────────────────────────── + #[test] fn person_serializes_with_enriched_fields() { let person = Person { @@ -13,7 +15,7 @@ fn person_serializes_with_enriched_fields() { outbox: Some("https://example.com/users/1/outbox".parse().unwrap()), followers: Some("https://example.com/users/1/followers".parse().unwrap()), following: Some("https://example.com/users/1/following".parse().unwrap()), - public_key: PublicKey { + public_key: activitypub_federation::protocol::public_key::PublicKey { id: "https://example.com/users/1#main-key".to_string(), owner: "https://example.com/users/1".parse().unwrap(), public_key_pem: "pem".to_string(), @@ -27,7 +29,7 @@ fn person_serializes_with_enriched_fields() { url: Some("https://example.com/u/alice".parse().unwrap()), discoverable: Some(true), manually_approves_followers: true, - updated: Some(Utc::now()), + updated: Some(chrono::Utc::now()), endpoints: Some(Endpoints { shared_inbox: "https://example.com/inbox".parse().unwrap(), }), @@ -47,4 +49,94 @@ fn person_serializes_with_enriched_fields() { json["endpoints"]["sharedInbox"], "https://example.com/inbox" ); + assert_eq!(json["featured"], "https://example.com/users/1/featured"); +} + +#[test] +fn person_actor_type_service_serializes_correctly() { + let mut person = minimal_person(); + person.kind = crate::user::ApActorType::Service; + let json = serde_json::to_value(&person).unwrap(); + assert_eq!(json["type"], "Service"); +} + +#[test] +fn person_discoverable_false_serializes() { + let mut person = minimal_person(); + person.discoverable = Some(false); + let json = serde_json::to_value(&person).unwrap(); + assert_eq!(json["discoverable"], false); +} + +#[test] +fn person_also_known_as_serializes_as_array() { + let mut person = minimal_person(); + person.also_known_as = vec![ + "https://old.example/users/alice".to_string(), + "https://other.example/users/alice".to_string(), + ]; + let json = serde_json::to_value(&person).unwrap(); + assert!( + json["alsoKnownAs"].is_array(), + "alsoKnownAs must serialize as a JSON array" + ); + assert_eq!(json["alsoKnownAs"].as_array().unwrap().len(), 2); +} + +#[test] +fn person_omits_optional_fields_when_none() { + let person = minimal_person(); + let json = serde_json::to_value(&person).unwrap(); + assert!( + json.get("summary").is_none(), + "null summary should be omitted" + ); + assert!(json.get("icon").is_none(), "null icon should be omitted"); + assert!( + json.get("featured").is_none(), + "null featured should be omitted" + ); + assert!(json.get("url").is_none(), "null url should be omitted"); +} + +#[test] +fn person_featured_omitted_when_none() { + let mut person = minimal_person(); + person.featured = None; + let json = serde_json::to_value(&person).unwrap(); + assert!(json.get("featured").is_none()); +} + +// ── helper ──────────────────────────────────────────────────────────────────── + +fn minimal_person() -> Person { + Person { + kind: Default::default(), + id: "https://example.com/users/1" + .parse::() + .unwrap() + .into(), + preferred_username: "alice".to_string(), + inbox: "https://example.com/users/1/inbox".parse().unwrap(), + outbox: None, + followers: None, + following: None, + public_key: activitypub_federation::protocol::public_key::PublicKey { + id: "https://example.com/users/1#main-key".to_string(), + owner: "https://example.com/users/1".parse().unwrap(), + public_key_pem: "pem".to_string(), + }, + name: None, + summary: None, + icon: None, + url: None, + discoverable: None, + manually_approves_followers: false, + updated: None, + endpoints: None, + image: None, + also_known_as: vec![], + attachment: vec![], + featured: None, + } } diff --git a/src/tests/broadcast.rs b/src/tests/broadcast.rs new file mode 100644 index 0000000..21d982f --- /dev/null +++ b/src/tests/broadcast.rs @@ -0,0 +1,57 @@ +/// Tests for broadcast addressing logic (visibility → to/cc fields). +use url::Url; + +use crate::service::broadcast::visibility_addressing; +use crate::urls::AS_PUBLIC; +use crate::user::ApVisibility; + +fn followers_url() -> Url { + "https://example.com/users/alice/followers".parse().unwrap() +} + +#[test] +fn public_visibility_addresses_public_and_followers() { + let (to, cc) = visibility_addressing(ApVisibility::Public, &followers_url()); + assert_eq!(to, vec![AS_PUBLIC.to_string()]); + assert_eq!(cc, vec![followers_url().to_string()]); +} + +#[test] +fn followers_only_visibility_addresses_followers_only() { + let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); + assert_eq!(to, vec![followers_url().to_string()]); + assert!( + cc.is_empty(), + "FollowersOnly must not include AS_PUBLIC in cc" + ); +} + +#[test] +fn followers_only_excludes_as_public() { + let (to, cc) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); + assert!( + !to.contains(&AS_PUBLIC.to_string()), + "FollowersOnly must not include AS_PUBLIC in to" + ); + assert!( + !cc.contains(&AS_PUBLIC.to_string()), + "FollowersOnly must not include AS_PUBLIC in cc" + ); +} + +#[test] +fn private_visibility_produces_empty_addressing() { + let (to, cc) = visibility_addressing(ApVisibility::Private, &followers_url()); + assert!(to.is_empty()); + assert!(cc.is_empty()); +} + +#[test] +fn public_and_followers_only_differ_in_to() { + let (pub_to, _) = visibility_addressing(ApVisibility::Public, &followers_url()); + let (fo_to, _) = visibility_addressing(ApVisibility::FollowersOnly, &followers_url()); + assert_ne!( + pub_to, fo_to, + "Public and FollowersOnly must produce different to fields" + ); +}