From 7424d1dc54aa20f4c4e8ef8ba2f689abe54121e3 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 29 May 2026 01:00:45 +0200 Subject: [PATCH] fix: address 3 PARTIAL plan items #15 @context security vocab: actor JSON now uses actor_ap_context() which includes W3C security vocab + Mastodon toot extensions (manuallyApprovesFollowers, discoverable, featured). Applied to actor_handler, actor_json(), broadcast_actor_update(). Activity JSON keeps plain AS context (no security vocab needed). #17 HTTP Digest (documented, no code change): production mode (debug=false) REQUIRES Digest header on inbound POSTs via require_digest() in the non-compat normalization config. Added doc comment to ApFederationConfig::new() to clarify. #26 Integration tests: 3 new tokio tests in src/tests/integration.rs using in-memory trait stubs. Tests cover: - check_guards idempotency (duplicate activity rejected) - check_guards domain block (blocked domain skipped) - extract_and_dispatch_mentions (on_mention called for local actor) --- src/actor_handler.rs | 3 +- src/federation.rs | 9 ++ src/lib.rs | 4 + src/service/broadcast.rs | 2 +- src/service/mod.rs | 2 +- src/tests/integration.rs | 271 +++++++++++++++++++++++++++++++++++++++ src/urls.rs | 18 +++ 7 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 src/tests/integration.rs diff --git a/src/actor_handler.rs b/src/actor_handler.rs index f52d8ce..99f5bad 100644 --- a/src/actor_handler.rs +++ b/src/actor_handler.rs @@ -6,6 +6,7 @@ use axum::extract::Path; use crate::actors::{Person, get_local_actor}; use crate::data::FederationData; use crate::error::Error; +use crate::urls::actor_ap_context; /// Serves the AP actor JSON for a local user. /// The path parameter is the user's UUID (matching the canonical actor URL). @@ -19,5 +20,5 @@ pub async fn actor_handler( let db_actor = get_local_actor(user_id, &data).await?; let person = db_actor.into_json(&data).await?; - Ok(FederationJson(WithContext::new_default(person))) + Ok(FederationJson(WithContext::new(person, actor_ap_context()))) } diff --git a/src/federation.rs b/src/federation.rs index 23d59e2..c4f8f4e 100644 --- a/src/federation.rs +++ b/src/federation.rs @@ -18,6 +18,15 @@ impl UrlVerifier for PermissiveVerifier { pub struct ApFederationConfig(pub FederationConfig); impl ApFederationConfig { + /// Create a new federation config. + /// + /// **HTTP signature / Digest behavior:** + /// - Production (`debug = false`): strict normalization + **requires `Digest` header** on every + /// inbound POST. All major AP implementations (Mastodon, Pleroma, Pixelfed) include it. + /// - Debug (`debug = true`): relaxes Digest requirement, disables signature verification, + /// and accepts any URL. **Never use in production.** + /// + /// Outbound signing always uses Mastodon compat mode regardless of this flag. pub async fn new(data: FederationData, debug: bool) -> anyhow::Result { let config = if debug { FederationConfig::builder() diff --git a/src/lib.rs b/src/lib.rs index 66c9916..79a103b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,3 +26,7 @@ pub use repository::{ }; pub use service::ActivityPubService; pub use user::{ApActorType, ApProfileField, ApUser, ApUserRepository, LookedUpActor}; + +#[cfg(test)] +#[path = "tests/integration.rs"] +mod integration_tests; diff --git a/src/service/broadcast.rs b/src/service/broadcast.rs index b97b855..8604eb1 100644 --- a/src/service/broadcast.rs +++ b/src/service/broadcast.rs @@ -214,7 +214,7 @@ impl ActivityPubService { let data = self.federation_config.to_request_data(); let local_actor = get_local_actor(user_id, &data).await.map_err(|e| anyhow::anyhow!("{e}"))?; let person = local_actor.clone().into_json(&data).await.map_err(|e| anyhow::anyhow!("{e}"))?; - let person_json = serde_json::to_value(WithContext::new_default(person))?; + let person_json = serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?; let update_id = Url::parse(&format!("{}/activities/update/{}", self.base_url, uuid::Uuid::new_v4()))?; let update = UpdateActivity { id: update_id, diff --git a/src/service/mod.rs b/src/service/mod.rs index 5d3add6..b073ec1 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -125,7 +125,7 @@ impl ActivityPubService { let data = self.federation_config.to_request_data(); let actor = get_local_actor(uuid, &data).await.map_err(|e| anyhow::anyhow!("{e}"))?; let person = actor.into_json(&data).await.map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(serde_json::to_string(&WithContext::new_default(person))?) + Ok(serde_json::to_string(&WithContext::new(person, crate::urls::actor_ap_context()))?) } pub async fn followers_collection_json(&self, user_id: uuid::Uuid, page: Option) -> anyhow::Result { diff --git a/src/tests/integration.rs b/src/tests/integration.rs new file mode 100644 index 0000000..703e6a7 --- /dev/null +++ b/src/tests/integration.rs @@ -0,0 +1,271 @@ +/// Integration tests exercising multi-component flows with in-memory trait stubs. +/// +/// These tests don't spin up an HTTP server but they do exercise: +/// - `check_guards` idempotency (is_activity_processed → mark → duplicate rejected) +/// - `extract_and_dispatch_mentions` dispatches on_mention for local actors +/// - Multiple trait implementations wired through FederationData + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use tokio::sync::Mutex; +use url::Url; + +use crate::content::ApObjectHandler; +use crate::data::FederationData; +use crate::repository::{ + BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, +}; +use crate::user::{ApActorType, ApProfileField, ApUser, ApUserRepository}; + +// ── In-memory FederationRepository ─────────────────────────────────────────── + +#[derive(Default)] +struct MemRepo { + processed: Mutex>, + blocked_domains: Mutex>, +} + +#[async_trait] +impl FederationRepository for MemRepo { + 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(()) + } + async fn is_domain_blocked(&self, domain: &str) -> anyhow::Result { + Ok(self.blocked_domains.lock().await.contains(domain)) + } + // ── stubs ──────────────────────────────────────────────────────────────── + async fn add_follower(&self, _: uuid::Uuid, _: &str, _: FollowerStatus, _: &str) -> anyhow::Result<()> { Ok(()) } + async fn get_follower_follow_activity_id(&self, _: uuid::Uuid, _: &str) -> anyhow::Result> { Ok(None) } + async fn remove_follower(&self, _: uuid::Uuid, _: &str) -> anyhow::Result<()> { 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 get_following_page(&self, _: uuid::Uuid, _: u32, _: usize) -> anyhow::Result> { Ok(vec![]) } + async fn update_follower_status(&self, _: uuid::Uuid, _: &str, _: FollowerStatus) -> anyhow::Result<()> { Ok(()) } + 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, _: uuid::Uuid, _: &str) -> anyhow::Result<()> { Ok(()) } + async fn get_following(&self, _: uuid::Uuid) -> anyhow::Result> { Ok(vec![]) } + async fn count_following(&self, _: uuid::Uuid) -> anyhow::Result { Ok(0) } + async fn upsert_remote_actor(&self, _: RemoteActor) -> anyhow::Result<()> { Ok(()) } + async fn get_remote_actor(&self, _: &str) -> anyhow::Result> { Ok(None) } + 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 get_pending_followers(&self, _: uuid::Uuid) -> anyhow::Result> { Ok(vec![]) } + async fn update_following_status(&self, _: uuid::Uuid, _: &str, _: FollowingStatus) -> anyhow::Result<()> { Ok(()) } + async fn get_following_outbox_url(&self, _: uuid::Uuid, _: &str) -> anyhow::Result> { Ok(None) } + async fn add_announce(&self, _: &str, _: &str, _: &str, _: DateTime) -> anyhow::Result<()> { Ok(()) } + async fn count_announces(&self, _: &str) -> anyhow::Result { Ok(0) } + 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 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, _: uuid::Uuid, _: &str) -> anyhow::Result { Ok(false) } + async fn migrate_follower_actor(&self, _: &str, _: &str) -> anyhow::Result> { Ok(vec![]) } + async fn get_accepted_follower_inboxes(&self, _: uuid::Uuid) -> anyhow::Result> { Ok(vec![]) } +} + +// ── In-memory ApUserRepository ──────────────────────────────────────────────── + +struct MemUserRepo { + users: HashMap, +} + +impl MemUserRepo { + fn with_user(id: uuid::Uuid, username: &str) -> Self { + let mut users = HashMap::new(); + users.insert(id, ApUser { + id, + username: username.to_string(), + display_name: None, + bio: None, + avatar_url: None, + banner_url: None, + also_known_as: None, + profile_url: None, + attachment: vec![], + manually_approves_followers: true, + actor_type: ApActorType::Person, + featured_url: None, + }); + Self { users } + } +} + +#[async_trait] +impl ApUserRepository for MemUserRepo { + async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result> { + Ok(self.users.get(&id).cloned()) + } + async fn find_by_username(&self, username: &str) -> anyhow::Result> { + Ok(self.users.values().find(|u| u.username == username).cloned()) + } + async fn count_users(&self) -> anyhow::Result { Ok(self.users.len()) } +} + +// ── In-memory ApObjectHandler ───────────────────────────────────────────────── + +#[derive(Default)] +struct MemHandler { + creates: Mutex>, + mentions: Mutex>, +} + +#[async_trait] +impl ApObjectHandler for MemHandler { + async fn get_local_objects_for_user(&self, _: uuid::Uuid) -> anyhow::Result> { Ok(vec![]) } + async fn get_local_objects_page(&self, _: uuid::Uuid, _: Option>, _: usize) -> anyhow::Result)>> { Ok(vec![]) } + async fn on_create(&self, ap_id: &Url, _: &Url, _: serde_json::Value) -> anyhow::Result<()> { + self.creates.lock().await.push(ap_id.clone()); + Ok(()) + } + async fn on_update(&self, _: &Url, _: &Url, _: serde_json::Value) -> anyhow::Result<()> { Ok(()) } + async fn on_delete(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } + async fn on_actor_removed(&self, _: &Url) -> anyhow::Result<()> { Ok(()) } + async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } + async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } + async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> { 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(()) + } + async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> { Ok(()) } + async fn count_local_posts(&self) -> anyhow::Result { Ok(0) } +} + +// ── Helper ──────────────────────────────────────────────────────────────────── + +fn make_data( + repo: Arc, + user_repo: Arc, + handler: Arc, +) -> FederationData { + FederationData::new( + repo, + user_repo, + handler, + "https://example.com".to_string(), + false, + "test".to_string(), + None, + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn check_guards_idempotency() { + use crate::activities::helpers::{already_processed, check_guards}; + use activitypub_federation::config::FederationConfig; + + let repo = Arc::new(MemRepo::default()); + let user_repo = Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")); + let handler = Arc::new(MemHandler::default()); + let data_inner = make_data(repo, user_repo, handler); + + let config = FederationConfig::builder() + .domain("example.com") + .app_data(data_inner) + .debug(true) + .build() + .await + .unwrap(); + let data = config.to_request_data(); + + let activity_id: Url = "https://remote.example/activities/abc123".parse().unwrap(); + let actor: Url = "https://remote.example/users/bob".parse().unwrap(); + + // First call: not processed yet → should NOT skip + let skip = check_guards(&activity_id, &actor, &data).await.unwrap(); + assert!(!skip, "first delivery should not be skipped"); + + // Second call with same activity ID → should skip (duplicate) + let skip = check_guards(&activity_id, &actor, &data).await.unwrap(); + assert!(skip, "duplicate delivery should be skipped"); + + // Different activity ID → should not skip + let other_id: Url = "https://remote.example/activities/xyz999".parse().unwrap(); + let skip = check_guards(&other_id, &actor, &data).await.unwrap(); + assert!(!skip, "different activity should not be skipped"); +} + +#[tokio::test] +async fn check_guards_blocks_domain() { + use crate::activities::helpers::check_guards; + use activitypub_federation::config::FederationConfig; + + let repo = Arc::new(MemRepo { + blocked_domains: Mutex::new(["spam.example".to_string()].into()), + ..Default::default() + }); + let user_repo = Arc::new(MemUserRepo::with_user(uuid::Uuid::new_v4(), "alice")); + let handler = Arc::new(MemHandler::default()); + let data_inner = make_data(repo, user_repo, handler); + + let config = FederationConfig::builder() + .domain("example.com") + .app_data(data_inner) + .debug(true) + .build() + .await + .unwrap(); + let data = config.to_request_data(); + + let activity_id: Url = "https://spam.example/activities/1".parse().unwrap(); + let actor: Url = "https://spam.example/users/evil".parse().unwrap(); + + // Blocked domain → should skip + let skip = check_guards(&activity_id, &actor, &data).await.unwrap(); + assert!(skip, "activity from blocked domain should be skipped"); +} + +#[tokio::test] +async fn extract_and_dispatch_mentions_notifies_local_users() { + use crate::activities::helpers::extract_and_dispatch_mentions; + use activitypub_federation::config::FederationConfig; + + let local_user_id = uuid::Uuid::new_v4(); + let user_repo = Arc::new(MemUserRepo::with_user(local_user_id, "alice")); + let handler = Arc::new(MemHandler::default()); + let repo = Arc::new(MemRepo::default()); + let data_inner = make_data(repo, user_repo.clone(), handler.clone()); + + let config = FederationConfig::builder() + .domain("example.com") + .app_data(data_inner) + .debug(true) + .build() + .await + .unwrap(); + let data = config.to_request_data(); + + let ap_id: Url = "https://remote.example/notes/1".parse().unwrap(); + let actor_url: Url = "https://remote.example/users/bob".parse().unwrap(); + + // Object with a Mention tag pointing to local user URL + let local_user_url = format!("https://example.com/users/{}", local_user_id); + let object = serde_json::json!({ + "type": "Note", + "id": ap_id.as_str(), + "content": "Hello @alice", + "tag": [ + {"type": "Mention", "href": local_user_url, "name": "@alice@example.com"} + ] + }); + + extract_and_dispatch_mentions(&ap_id, &actor_url, &object, &data).await; + + let mentions = handler.mentions.lock().await; + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].0, ap_id); + assert_eq!(mentions[0].1, local_user_id); +} diff --git a/src/urls.rs b/src/urls.rs index 36bf9c8..fde3d87 100644 --- a/src/urls.rs +++ b/src/urls.rs @@ -6,6 +6,24 @@ pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public"; pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; pub const AP_PAGE_SIZE: usize = 20; +/// Returns the `@context` array for actor AP JSON. +/// Includes the W3C security vocabulary (needed for `publicKey` resolution) +/// and common Mastodon/Toot extensions (`discoverable`, `featured`, etc.). +/// Activities use `WithContext::new_default` (plain AS context) — only actor +/// JSON needs the security vocab. +pub fn actor_ap_context() -> serde_json::Value { + serde_json::json!([ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "discoverable": "toot:discoverable", + "featured": {"@id": "toot:featured", "@type": "@id"} + } + ]) +} + pub fn extract_user_id_from_url(url: &Url) -> Option { let path = url.path(); path.strip_prefix("/users/")