fediverse_signal_service.rs 1428 lignes · 54543 octets
//! Fediverse lifecycle signal handlers (RFC 0010).
//!
//! Applies incoming AP `Update{Actor}` / `Delete{Actor}` /
//! `Revoke{Membership}` signals to the local state. The HTTP-Sig
//! verification of the request that carried the signal is delegated to
//! `SignatureVerifier` — the implementation that ships with this RFC
//! is `AlwaysAcceptVerifier` (test-only); a follow-up RFC will plug
//! the real RFC 9421 verifier.
//!

use std::sync::Arc;

use async_trait::async_trait;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use uuid::Uuid;

use crate::models::remote_identity;
use crate::observer::{FediverseEventObserver, NoopFediverseObserver};
use rustwarden_core::error::AppError;
use rustwarden_core::models::team_member;

/// Carries the actor metadata extracted from an inbound `Update{Actor}`
/// activity.
#[derive(Debug, Clone)]
pub struct UpdatedActor {
    /// Canonical AP `id`. Match key against `remote_identities.ap_id`.
    pub ap_id: String,
    /// Optional display name (`name` field on the AP `Person`).
    pub display_name: Option<String>,
    /// Optional bio (`summary` field).
    pub summary_html: Option<String>,
    /// Optional avatar URL (`icon.url`).
    pub icon_url: Option<String>,
    /// Optional refreshed public key.
    pub public_key_pem: Option<String>,
    /// Optional raw activity body for audit / re-processing.
    pub raw_json: Option<serde_json::Value>,
}

/// Counters returned by [`FediverseSignalService::apply_delete_actor_with_hooks`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DeleteOutcome {
    /// Number of `remote_identities` rows soft-deleted.
    pub rows_disabled: u64,
    /// Number of JWTs blacklisted (always `0` when no
    /// [`DeleteActorHooks::iterator`] is supplied).
    pub jwts_blacklisted: u64,
}

/// Pluggable side-effects for a remote-actor deletion (RFC 0033).
///
/// All fields are `Option` with `None` as default (no-op). The
/// recommended construction idiom is the struct-literal pattern with
/// `..Default::default()`, which keeps existing call-sites compiling
/// when new hooks get added in future RFCs:
///
/// ```rust,ignore
/// use rustwarden_fediverse::services::fediverse_signal_service::DeleteActorHooks;
/// let hooks = DeleteActorHooks {
///     observer: Some(&my_observer),
///     ..Default::default()
/// };
/// ```
///
/// The struct is **not** `#[non_exhaustive]` so consumers can keep
/// instantiating it with struct literals; rely on
/// `..Default::default()` for forward-compatibility instead.
#[derive(Default)]
pub struct DeleteActorHooks<'a> {
    /// Best-effort observer notified **after** the `disabled_at`
    /// flip has been committed (RFC 0032). `None` ⇒ no hook fires.
    pub observer: Option<&'a dyn FediverseEventObserver>,
    /// Enumerator of the active JWTs to blacklist for the deleted
    /// remote identity (RFC 0018). `None` ⇒ `jwts_blacklisted = 0`.
    pub iterator:
        Option<&'a dyn rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator>,
}

/// Stateless service that applies AP lifecycle signals.
pub struct FediverseSignalService;

impl FediverseSignalService {
    /// Apply an inbound `Update{Actor}`: upsert the row keyed by
    /// `ap_id`. The local fields not carried by `actor` are preserved
    /// (no destructive overwrite).
    pub async fn apply_update_actor(
        db: &DatabaseConnection,
        actor: UpdatedActor,
    ) -> Result<remote_identity::Model, AppError> {
        let existing = remote_identity::Entity::find()
            .filter(remote_identity::Column::ApId.eq(actor.ap_id.clone()))
            .one(db)
            .await?;

        let now = Utc::now();
        if let Some(model) = existing {
            let mut active: remote_identity::ActiveModel = model.into();
            if let Some(name) = actor.display_name {
                active.display_name = Set(Some(name));
            }
            if let Some(summary) = actor.summary_html {
                active.summary_html = Set(Some(summary));
            }
            if let Some(icon) = actor.icon_url {
                active.icon_url = Set(Some(icon));
            }
            if let Some(pem) = actor.public_key_pem {
                active.public_key_pem = Set(pem);
            }
            if let Some(raw) = actor.raw_json {
                active.raw_json = Set(Some(raw));
            }
            active.last_fetched_at = Set(now);
            Ok(active.update(db).await?)
        } else {
            // Minimal insert. The caller (inbox handler) is responsible
            // for filling in the rest via webfinger lookup before
            // calling apply_update_actor — but if the row truly is new
            // we still want to persist *something* rather than 404.
            let username_domain = parse_handle_from_ap_id(&actor.ap_id)
                .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
            let active = remote_identity::ActiveModel {
                id: Set(Uuid::new_v4()),
                ap_id: Set(actor.ap_id.clone()),
                username: Set(username_domain.0),
                domain: Set(username_domain.1),
                display_name: Set(actor.display_name),
                summary_html: Set(actor.summary_html),
                inbox_url: Set(format!("{}/inbox", actor.ap_id)),
                shared_inbox_url: Set(None),
                public_key_pem: Set(actor.public_key_pem.unwrap_or_default()),
                icon_url: Set(actor.icon_url),
                actor_type: Set("Person".to_string()),
                raw_json: Set(actor.raw_json),
                last_fetched_at: Set(now),
                disabled_at: Set(None),
                created_at: Set(now),
                updated_at: Set(now),
            };
            Ok(active.insert(db).await?)
        }
    }

    /// Canonical entrypoint for an inbound `Delete{Actor}` (RFC 0033).
    ///
    /// Combines the semantics of every historical variant:
    ///
    /// 1. Flip `remote_identities.disabled_at` to `NOW()`
    ///    (idempotent: a re-delivery does nothing).
    /// 2. If [`DeleteActorHooks::iterator`] is `Some`, blacklist every
    ///    active JWT enumerated for the deleted `remote_identity_id`
    ///    (RFC 0018).
    /// 3. If [`DeleteActorHooks::observer`] is `Some`, fire
    ///    [`FediverseEventObserver::on_delete_actor`] **after** the
    ///    state mutation is committed (RFC 0032).
    ///
    /// Steps 2 and 3 only run on a real transition (active →
    /// disabled). Re-deliveries and unknown actors are silent.
    pub async fn apply_delete_actor_with_hooks(
        db: &DatabaseConnection,
        ap_id: &str,
        hooks: DeleteActorHooks<'_>,
    ) -> Result<DeleteOutcome, AppError> {
        let Some(row) = remote_identity::Entity::find()
            .filter(remote_identity::Column::ApId.eq(ap_id))
            .one(db)
            .await?
        else {
            return Ok(DeleteOutcome::default());
        };

        if row.disabled_at.is_some() {
            return Ok(DeleteOutcome {
                rows_disabled: 0,
                jwts_blacklisted: 0,
            });
        }

        let domain = row.domain.clone();
        let remote_identity_id = row.id;

        let mut active: remote_identity::ActiveModel = row.into();
        active.disabled_at = Set(Some(Utc::now()));
        active.update(db).await?;

        let jwts_blacklisted = if let Some(it) = hooks.iterator {
            rustwarden_core::services::jwt_blacklist_service::JwtBlacklistService::blacklist_by_remote_identity_id(
                db,
                remote_identity_id,
                it,
            )
            .await?
        } else {
            0
        };

        if let Some(observer) = hooks.observer {
            observer
                .on_delete_actor(&domain, ap_id, remote_identity_id)
                .await;
        }

        Ok(DeleteOutcome {
            rows_disabled: 1,
            jwts_blacklisted,
        })
    }

    /// Compatibility wrapper. Prefer
    /// [`Self::apply_delete_actor_with_hooks`] for new code.
    pub async fn apply_delete_actor(
        db: &DatabaseConnection,
        ap_id: &str,
    ) -> Result<DeleteOutcome, AppError> {
        Self::apply_delete_actor_with_hooks(db, ap_id, DeleteActorHooks::default()).await
    }

    /// Compatibility wrapper. Prefer
    /// [`Self::apply_delete_actor_with_hooks`] for new code.
    pub async fn apply_delete_actor_observed(
        db: &DatabaseConnection,
        ap_id: &str,
        observer: &dyn FediverseEventObserver,
    ) -> Result<DeleteOutcome, AppError> {
        Self::apply_delete_actor_with_hooks(
            db,
            ap_id,
            DeleteActorHooks {
                observer: Some(observer),
                ..Default::default()
            },
        )
        .await
    }

    /// Compatibility wrapper. Prefer
    /// [`Self::apply_delete_actor_with_hooks`] for new code.
    pub async fn apply_delete_actor_with_iterator(
        db: &DatabaseConnection,
        ap_id: &str,
        iterator: &dyn rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator,
    ) -> Result<DeleteOutcome, AppError> {
        Self::apply_delete_actor_with_hooks(
            db,
            ap_id,
            DeleteActorHooks {
                iterator: Some(iterator),
                ..Default::default()
            },
        )
        .await
    }

    /// Apply an inbound `Revoke{Membership}`: drop the matching
    /// `team_members` row. Returns `true` if a row was deleted.
    pub async fn apply_revoke_membership(
        db: &DatabaseConnection,
        team_id: Uuid,
        remote_identity_id: Uuid,
    ) -> Result<bool, AppError> {
        let result = team_member::Entity::delete_many()
            .filter(team_member::Column::TeamId.eq(team_id))
            .filter(team_member::Column::RemoteIdentityId.eq(Some(remote_identity_id)))
            .exec(db)
            .await?;
        Ok(result.rows_affected > 0)
    }
}

/// Inbound request context passed to [`SignatureVerifier::verify`]
/// (RFC 0016). Carries every field needed by draft-cavage HTTP
/// signature verification: method + path + headers + body + the
/// actor's resolved public key.
#[derive(Debug)]
pub struct VerifyCtx<'a> {
    /// AP id of the signer (extracted from the body by the caller).
    pub actor_ap_id: &'a str,
    /// HTTP method, lowercased (`"post"`).
    pub method: &'a str,
    /// Path-and-query (`"/inbox"`).
    pub path: &'a str,
    /// All inbound headers (case-insensitive lookup at the caller).
    pub headers: &'a axum::http::HeaderMap,
    /// Raw body bytes (used by the caller for digest / audit).
    pub body: &'a [u8],
    /// Resolved PEM public key for `actor_ap_id`. Empty when the
    /// caller has not yet resolved the actor; impls may either
    /// reject or fetch on their own.
    pub public_key_pem: &'a str,
}

/// HTTP-Sig verifier for inbound AP activities.
///
/// Implementations decide whether to accept a given signature. The
/// `AlwaysAcceptVerifier` shipped here is **test/dev only**.
/// `HttpSigVerifier` from
/// [`crate::http_signatures`] is the production
/// implementation.
#[async_trait]
pub trait SignatureVerifier: Send + Sync {
    /// Verify a signature given the full request context.
    async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>;
}

/// Test/dev verifier that accepts any signature.
///
/// **Never use in production.** A logged warning emphasises the
/// hazard at instantiation in production builds.
///
/// When constructed with [`AlwaysAcceptVerifier::with_resolver`], the
/// verifier also fetches the actor over HTTP and upserts the
/// `remote_identities` row before returning `Ok(())` — this preserves
/// the contract documented by [`crate::services::fediverse_auth_service::FediverseAuthService::persist_inbound_request`]
/// (« the signature verifier ran upstream and already required this
/// row ») so downstream services don't need to special-case the debug
/// verifier.
pub struct AlwaysAcceptVerifier {
    resolver: Option<(DatabaseConnection, reqwest::Client)>,
    observer: Arc<dyn FediverseEventObserver>,
}

impl AlwaysAcceptVerifier {
    /// Verifier with no side-effects. Suitable for unit tests where the
    /// caller pre-seeds `remote_identities` themselves.
    pub fn new() -> Self {
        Self {
            resolver: None,
            observer: Arc::new(NoopFediverseObserver),
        }
    }

    /// Verifier that, on each accepted request, fetches the actor doc
    /// from `ctx.actor_ap_id` over HTTP and upserts the corresponding
    /// `remote_identities` cache row. Best-effort: a fetch/parse failure
    /// is logged and the request is still accepted (since this verifier
    /// is « always accept » by contract).
    ///
    /// The internal HTTP client has [`ACTOR_FETCH_TIMEOUT`] applied. The
    /// SSRF guard inside [`fetch_and_cache_remote_actor`] is **bypassed**
    /// here (`allow_private_ips=true`) because this verifier is test/dev
    /// only and the docker-compose testbed lives on private IPs.
    pub fn with_resolver(db: DatabaseConnection) -> Self {
        let http = reqwest::Client::builder()
            .timeout(ACTOR_FETCH_TIMEOUT)
            .build()
            .expect("reqwest client builder must not fail");
        Self {
            resolver: Some((db, http)),
            observer: Arc::new(NoopFediverseObserver),
        }
    }

    /// Attach a [`FediverseEventObserver`] so that the fetch-and-cache
    /// side-effect emits granular events (SSRF rejection, actor
    /// impersonation) to the guard pipeline (RFC 0028). Default is the
    /// silent [`NoopFediverseObserver`].
    #[must_use]
    pub fn with_observer(mut self, observer: Arc<dyn FediverseEventObserver>) -> Self {
        self.observer = observer;
        self
    }
}

impl Default for AlwaysAcceptVerifier {
    fn default() -> Self {
        Self::new()
    }
}

#[async_trait]
impl SignatureVerifier for AlwaysAcceptVerifier {
    async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> {
        tracing::warn!(
            actor = %ctx.actor_ap_id,
            "AlwaysAcceptVerifier in use — DO NOT enable in production",
        );
        if let Some((db, http)) = &self.resolver
            && let Err(e) = fetch_and_cache_remote_actor_observed(
                db,
                http,
                ctx.actor_ap_id,
                /* allow_private_ips */ true,
                self.observer.as_ref(),
            )
            .await
        {
            // « Always accept » applies to signature verification, not
            // to the cache-side-effect: a failed fetch must not 401 the
            // caller. Log and let downstream services decide what to do
            // with an absent row.
            tracing::warn!(
                actor = %ctx.actor_ap_id,
                error = %e,
                "AlwaysAcceptVerifier: actor fetch+cache failed (continuing)",
            );
        }
        Ok(())
    }
}

/// Maximum body size accepted from a remote actor fetch. Mastodon's
/// canonical Person doc is ~2 KB; 1 MiB leaves room for icons in
/// `attachment` and large `summary` fields while bounding DoS exposure.
pub const MAX_ACTOR_BODY_BYTES: u64 = 1024 * 1024;

/// Per-request timeout applied to every outgoing actor fetch. Caps
/// hangs from unreachable / slow remote instances without breaking the
/// inbox handler that called the verifier.
pub const ACTOR_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);

/// Result of [`ssrf_guard_classified`] when it rejects: carries the IP
/// the guard caught so callers can forward it to the
/// [`FediverseEventObserver`].
#[derive(Debug, Clone)]
pub(crate) struct SsrfRejection {
    pub(crate) host: String,
    pub(crate) attempted_ip: Option<String>,
    pub(crate) message: String,
}

impl From<SsrfRejection> for AppError {
    fn from(value: SsrfRejection) -> Self {
        AppError::Validation(value.message)
    }
}

/// Refuse to fetch URLs whose resolved IP is loopback, private,
/// link-local, ULA, or unspecified. Prevents SSRF pivots through
/// `actor_ap_id` values like `http://169.254.169.254/...` (AWS
/// metadata) or `http://10.x.x.x/admin`. Returns a structured
/// [`SsrfRejection`] on rejection so the caller can forward host + IP
/// to the [`FediverseEventObserver`].
///
/// Caller passes `allow_private_ips=true` only in test/dev contexts
/// where the topology is local (e.g. the docker-compose testbed using
/// `*.local` aliases resolving to 172.x.x.x).
async fn ssrf_guard_classified(
    url: &url::Url,
    allow_private_ips: bool,
) -> Result<(), SsrfRejection> {
    if allow_private_ips {
        return Ok(());
    }
    let host = url.host_str().ok_or_else(|| SsrfRejection {
        host: String::new(),
        attempted_ip: None,
        message: "URL has no host".into(),
    })?;
    let port = url.port_or_known_default().unwrap_or(80);
    let addrs = tokio::net::lookup_host((host, port))
        .await
        .map_err(|e| SsrfRejection {
            host: host.to_string(),
            attempted_ip: None,
            message: format!("DNS resolution failed for {host}: {e}"),
        })?;
    for addr in addrs {
        use std::net::IpAddr;
        let ip = addr.ip();
        if ip.is_loopback() || ip.is_unspecified() {
            return Err(SsrfRejection {
                host: host.to_string(),
                attempted_ip: Some(ip.to_string()),
                message: format!(
                    "refusing to fetch {host}: resolves to loopback/unspecified ({ip})"
                ),
            });
        }
        match ip {
            IpAddr::V4(v4) => {
                if v4.is_private() || v4.is_link_local() {
                    return Err(SsrfRejection {
                        host: host.to_string(),
                        attempted_ip: Some(v4.to_string()),
                        message: format!(
                            "refusing to fetch {host}: resolves to private/link-local ({v4})"
                        ),
                    });
                }
            }
            IpAddr::V6(v6) => {
                let seg0 = v6.segments()[0];
                if (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 {
                    return Err(SsrfRejection {
                        host: host.to_string(),
                        attempted_ip: Some(v6.to_string()),
                        message: format!(
                            "refusing to fetch {host}: resolves to ULA/link-local ({v6})"
                        ),
                    });
                }
            }
        }
    }
    Ok(())
}

/// Fetch a remote AP `Person` actor over HTTP and upsert the matching
/// `remote_identities` row. Idempotent: a re-fetch refreshes
/// `public_key_pem`, `inbox_url`, `shared_inbox_url`, `display_name`,
/// and `last_fetched_at`.
///
/// Hardening:
/// - 10 s per-request timeout (via the caller's `http` client).
/// - 1 MiB body cap via Content-Length sniff + post-read assertion.
/// - SSRF guard rejecting private/loopback/link-local IPs unless
///   `allow_private_ips=true` (only set by test/dev callers).
///
/// Network/parse errors return [`AppError::Validation`]; the caller
/// chooses whether to surface them (production verifier) or swallow
/// them (test/dev verifier).
pub async fn fetch_and_cache_remote_actor(
    db: &DatabaseConnection,
    http: &reqwest::Client,
    actor_ap_id: &str,
    allow_private_ips: bool,
) -> Result<remote_identity::Model, AppError> {
    fetch_and_cache_remote_actor_observed(
        db,
        http,
        actor_ap_id,
        allow_private_ips,
        &NoopFediverseObserver,
    )
    .await
}

/// Variant of [`fetch_and_cache_remote_actor`] that emits
/// [`FediverseEventObserver`] events on every SSRF rejection and on
/// CVE-2024-23832 actor-impersonation mismatches.
pub async fn fetch_and_cache_remote_actor_observed(
    db: &DatabaseConnection,
    http: &reqwest::Client,
    actor_ap_id: &str,
    allow_private_ips: bool,
    observer: &dyn FediverseEventObserver,
) -> Result<remote_identity::Model, AppError> {
    let url = url::Url::parse(actor_ap_id)
        .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not a valid URL: {e}")))?;

    if let Err(rejection) = ssrf_guard_classified(&url, allow_private_ips).await {
        observer
            .on_fetch_ssrf(&rejection.host, rejection.attempted_ip.as_deref())
            .await;
        return Err(AppError::from(rejection));
    }

    let response = http
        .get(actor_ap_id)
        .header("Accept", "application/activity+json")
        .send()
        .await
        .map_err(|e| AppError::Validation(format!("cannot fetch actor {actor_ap_id}: {e}")))?;

    if !response.status().is_success() {
        return Err(AppError::Validation(format!(
            "actor fetch returned {} for {actor_ap_id}",
            response.status()
        )));
    }

    // Belt: refuse early if the server advertises a body larger than
    // our cap. Suspenders: re-check after the body is fully buffered,
    // because Content-Length is optional and can lie.
    if let Some(len) = response.content_length()
        && len > MAX_ACTOR_BODY_BYTES
    {
        return Err(AppError::Validation(format!(
            "actor {actor_ap_id} body too large: {len} > {MAX_ACTOR_BODY_BYTES}"
        )));
    }
    let bytes = response
        .bytes()
        .await
        .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} read error: {e}")))?;
    if (bytes.len() as u64) > MAX_ACTOR_BODY_BYTES {
        return Err(AppError::Validation(format!(
            "actor {actor_ap_id} body too large: {} > {MAX_ACTOR_BODY_BYTES}",
            bytes.len()
        )));
    }

    let body: serde_json::Value = serde_json::from_slice(&bytes)
        .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not valid JSON: {e}")))?;

    // CVE-2024-23832 parity (ANSSI F-AP-1) — refuse to cache an actor
    // document whose `id` does not match the URL we fetched. Without
    // this check, `evil.tld` could host a document claiming
    // `id="https://victim.tld/users/alice"` and a downstream lookup
    // keyed on the impersonated id would resolve to the attacker's
    // public key. The mismatch is also reported to the observer so
    // detectors can ban the lying host.
    if let Some(declared_id) = body.get("id").and_then(|v| v.as_str())
        && declared_id != actor_ap_id
    {
        let host = url
            .host_str()
            .map(|s| s.to_ascii_lowercase())
            .unwrap_or_default();
        observer
            .on_inbox_abuse(&host, Some(actor_ap_id), "actor_impersonation")
            .await;
        return Err(AppError::Validation(format!(
            "actor {actor_ap_id} document `id` mismatch: claims `{declared_id}`",
        )));
    }

    let preferred_username = body
        .get("preferredUsername")
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            AppError::Validation(format!("actor {actor_ap_id} missing preferredUsername"))
        })?
        .to_string();

    let domain = url
        .host_str()
        .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} has no host")))?
        .to_string();

    let public_key_pem = body
        .get("publicKey")
        .and_then(|v| v.get("publicKeyPem"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            AppError::Validation(format!(
                "actor {actor_ap_id} missing publicKey.publicKeyPem"
            ))
        })?
        .to_string();

    let inbox_url = body
        .get("inbox")
        .and_then(|v| v.as_str())
        .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} missing inbox")))?
        .to_string();

    let shared_inbox_url = body
        .get("endpoints")
        .and_then(|v| v.get("sharedInbox"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let display_name = body
        .get("name")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let now = Utc::now();
    let existing = remote_identity::Entity::find()
        .filter(remote_identity::Column::ApId.eq(actor_ap_id))
        .one(db)
        .await?;

    let model = if let Some(row) = existing {
        let mut active: remote_identity::ActiveModel = row.into();
        active.public_key_pem = Set(public_key_pem);
        active.inbox_url = Set(inbox_url);
        active.shared_inbox_url = Set(shared_inbox_url);
        active.display_name = Set(display_name);
        active.raw_json = Set(Some(body));
        active.last_fetched_at = Set(now);
        active.updated_at = Set(now);
        active.update(db).await?
    } else {
        let active = remote_identity::ActiveModel {
            id: Set(Uuid::new_v4()),
            ap_id: Set(actor_ap_id.to_string()),
            username: Set(preferred_username),
            domain: Set(domain),
            display_name: Set(display_name),
            summary_html: Set(None),
            inbox_url: Set(inbox_url),
            shared_inbox_url: Set(shared_inbox_url),
            public_key_pem: Set(public_key_pem),
            icon_url: Set(None),
            actor_type: Set("Person".to_string()),
            raw_json: Set(Some(body)),
            last_fetched_at: Set(now),
            disabled_at: Set(None),
            created_at: Set(now),
            updated_at: Set(now),
        };
        active.insert(db).await?
    };
    Ok(model)
}

fn parse_handle_from_ap_id(ap_id: &str) -> Option<(String, String)> {
    let url = url::Url::parse(ap_id).ok()?;
    let host = url.host_str()?.to_string();
    let user = url.path_segments()?.next_back()?.to_string();
    Some((user, host))
}

#[cfg(test)]
mod tests {
    use super::*;
    use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};

    fn fresh_remote(id: Uuid, ap_id: &str) -> remote_identity::Model {
        let now = Utc::now();
        remote_identity::Model {
            id,
            ap_id: ap_id.to_string(),
            username: "alice".into(),
            domain: "a.tld".into(),
            display_name: Some("Alice".into()),
            summary_html: None,
            inbox_url: format!("{ap_id}/inbox"),
            shared_inbox_url: None,
            public_key_pem: "-----BEGIN PUBLIC KEY-----\n".into(),
            icon_url: None,
            actor_type: "Person".into(),
            raw_json: None,
            last_fetched_at: now,
            disabled_at: None,
            created_at: now,
            updated_at: now,
        }
    }

    // ─── Pure ────────────────────────────────────────────────────────

    #[test]
    fn delete_outcome_default_is_zero() {
        assert_eq!(DeleteOutcome::default().rows_disabled, 0);
        assert_eq!(DeleteOutcome::default().jwts_blacklisted, 0);
    }

    // ─── apply_update_actor ─────────────────────────────────────────

    #[tokio::test]
    async fn apply_update_actor_upserts_when_existing() {
        let id = Uuid::new_v4();
        let row = fresh_remote(id, "https://a.tld/users/alice");
        let mut after = row.clone();
        after.display_name = Some("Alice McUpdated".into());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();

        let updated = FediverseSignalService::apply_update_actor(
            &db,
            UpdatedActor {
                ap_id: "https://a.tld/users/alice".into(),
                display_name: Some("Alice McUpdated".into()),
                summary_html: None,
                icon_url: None,
                public_key_pem: None,
                raw_json: None,
            },
        )
        .await
        .unwrap();
        assert_eq!(updated.display_name.as_deref(), Some("Alice McUpdated"));
    }

    #[tokio::test]
    async fn apply_update_actor_inserts_when_missing() {
        let id = Uuid::new_v4();
        let new_row = fresh_remote(id, "https://a.tld/users/bob");
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            // 1. SELECT existing → empty
            .append_query_results([Vec::<remote_identity::Model>::new()])
            // 2. INSERT returns the row
            .append_query_results([vec![new_row.clone()]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();

        let inserted = FediverseSignalService::apply_update_actor(
            &db,
            UpdatedActor {
                ap_id: "https://a.tld/users/bob".into(),
                display_name: None,
                summary_html: None,
                icon_url: None,
                public_key_pem: None,
                raw_json: None,
            },
        )
        .await
        .unwrap();
        assert_eq!(inserted.ap_id, new_row.ap_id);
    }

    // ─── apply_delete_actor ─────────────────────────────────────────

    #[tokio::test]
    async fn apply_delete_actor_sets_disabled_at_and_returns_one() {
        let id = Uuid::new_v4();
        let row = fresh_remote(id, "https://a.tld/users/alice");
        let mut after = row.clone();
        after.disabled_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let outcome = FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice")
            .await
            .unwrap();
        assert_eq!(outcome.rows_disabled, 1);
        assert_eq!(outcome.jwts_blacklisted, 0);
    }

    #[tokio::test]
    async fn apply_delete_actor_returns_zero_when_no_row() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<remote_identity::Model>::new()])
            .into_connection();
        let outcome = FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/ghost")
            .await
            .unwrap();
        assert_eq!(outcome, DeleteOutcome::default());
    }

    #[tokio::test]
    async fn apply_delete_actor_returns_zero_when_already_disabled() {
        let id = Uuid::new_v4();
        let mut row = fresh_remote(id, "https://a.tld/users/alice");
        row.disabled_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let outcome = FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice")
            .await
            .unwrap();
        assert_eq!(outcome.rows_disabled, 0);
    }

    // ─── apply_delete_actor_observed (RFC 0032) ─────────────────────

    #[derive(Default)]
    struct CapturingDeleteObserver {
        calls: std::sync::Mutex<Vec<(String, String, Uuid)>>,
    }

    #[async_trait]
    impl FediverseEventObserver for CapturingDeleteObserver {
        async fn on_delete_actor(&self, domain: &str, ap_id: &str, remote_identity_id: Uuid) {
            self.calls.lock().unwrap().push((
                domain.to_string(),
                ap_id.to_string(),
                remote_identity_id,
            ));
        }
    }

    #[tokio::test]
    async fn apply_delete_actor_observed_fires_hook_on_first_disable() {
        let id = Uuid::new_v4();
        let row = fresh_remote(id, "https://a.tld/users/alice");
        let mut after = row.clone();
        after.disabled_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();

        let observer = CapturingDeleteObserver::default();
        let outcome = FediverseSignalService::apply_delete_actor_observed(
            &db,
            "https://a.tld/users/alice",
            &observer,
        )
        .await
        .unwrap();

        assert_eq!(outcome.rows_disabled, 1);
        let calls = observer.calls.lock().unwrap().clone();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].0, "a.tld");
        assert_eq!(calls[0].1, "https://a.tld/users/alice");
        assert_eq!(calls[0].2, id);
    }

    #[tokio::test]
    async fn apply_delete_actor_observed_does_not_fire_when_no_row() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<remote_identity::Model>::new()])
            .into_connection();
        let observer = CapturingDeleteObserver::default();
        let outcome = FediverseSignalService::apply_delete_actor_observed(
            &db,
            "https://a.tld/users/ghost",
            &observer,
        )
        .await
        .unwrap();
        assert_eq!(outcome, DeleteOutcome::default());
        assert!(observer.calls.lock().unwrap().is_empty());
    }

    #[tokio::test]
    async fn apply_delete_actor_observed_does_not_fire_when_already_disabled() {
        let id = Uuid::new_v4();
        let mut row = fresh_remote(id, "https://a.tld/users/alice");
        row.disabled_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let observer = CapturingDeleteObserver::default();
        let outcome = FediverseSignalService::apply_delete_actor_observed(
            &db,
            "https://a.tld/users/alice",
            &observer,
        )
        .await
        .unwrap();
        assert_eq!(outcome.rows_disabled, 0);
        assert!(observer.calls.lock().unwrap().is_empty());
    }

    // ─── apply_revoke_membership ────────────────────────────────────

    #[tokio::test]
    async fn apply_revoke_membership_returns_false_when_no_row() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 0,
            }])
            .into_connection();
        let revoked =
            FediverseSignalService::apply_revoke_membership(&db, Uuid::new_v4(), Uuid::new_v4())
                .await
                .unwrap();
        assert!(!revoked);
    }

    #[tokio::test]
    async fn apply_revoke_membership_returns_true_when_row_dropped() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let revoked =
            FediverseSignalService::apply_revoke_membership(&db, Uuid::new_v4(), Uuid::new_v4())
                .await
                .unwrap();
        assert!(revoked);
    }

    // ─── SignatureVerifier ──────────────────────────────────────────

    fn dummy_ctx<'a>(headers: &'a axum::http::HeaderMap) -> VerifyCtx<'a> {
        VerifyCtx {
            actor_ap_id: "https://a.tld/users/alice",
            method: "post",
            path: "/inbox",
            headers,
            body: b"",
            public_key_pem: "",
        }
    }

    #[tokio::test]
    async fn always_accept_verifier_accepts_any_input() {
        let headers = axum::http::HeaderMap::new();
        AlwaysAcceptVerifier::new()
            .verify(&dummy_ctx(&headers))
            .await
            .expect("AlwaysAcceptVerifier never rejects");
    }

    #[tokio::test]
    async fn signature_verifier_trait_object_works() {
        let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier::new());
        let headers = axum::http::HeaderMap::new();
        verifier
            .verify(&dummy_ctx(&headers))
            .await
            .expect("trait object dispatches");
    }

    // ─── RFC 0018 — apply_delete_actor_with_iterator ────────────────

    #[tokio::test]
    async fn apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero() {
        use rustwarden_core::services::jwt_blacklist_service::NoopActiveTokenIterator;
        let id = Uuid::new_v4();
        let row = fresh_remote(id, "https://a.tld/users/alice");
        let mut after = row.clone();
        after.disabled_at = Some(Utc::now());
        // RFC 0033: the unified path needs only SELECT + UPDATE (no
        // re-fetch); the iterator is a Noop so no blacklist write.
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();
        let outcome = FediverseSignalService::apply_delete_actor_with_iterator(
            &db,
            "https://a.tld/users/alice",
            &NoopActiveTokenIterator,
        )
        .await
        .unwrap();
        assert_eq!(outcome.rows_disabled, 1);
        assert_eq!(outcome.jwts_blacklisted, 0);
    }

    // ─── apply_delete_actor_with_hooks (RFC 0033) ───────────────────

    #[test]
    fn delete_actor_hooks_default_is_all_none() {
        let hooks = DeleteActorHooks::default();
        assert!(hooks.observer.is_none());
        assert!(hooks.iterator.is_none());
    }

    #[tokio::test]
    async fn apply_delete_actor_with_hooks_combines_observer_and_iterator() {
        use rustwarden_core::services::jwt_blacklist_service::NoopActiveTokenIterator;
        let id = Uuid::new_v4();
        let row = fresh_remote(id, "https://a.tld/users/alice");
        let mut after = row.clone();
        after.disabled_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .append_query_results([vec![after]])
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();

        let observer = CapturingDeleteObserver::default();
        let iterator = NoopActiveTokenIterator;
        let hooks = DeleteActorHooks {
            observer: Some(&observer),
            iterator: Some(&iterator),
        };

        let outcome = FediverseSignalService::apply_delete_actor_with_hooks(
            &db,
            "https://a.tld/users/alice",
            hooks,
        )
        .await
        .unwrap();

        assert_eq!(outcome.rows_disabled, 1);
        assert_eq!(outcome.jwts_blacklisted, 0);
        let calls = observer.calls.lock().unwrap().clone();
        assert_eq!(calls.len(), 1);
        assert_eq!(calls[0].2, id);
    }

    #[tokio::test]
    async fn apply_delete_actor_with_hooks_skips_both_when_already_disabled() {
        use rustwarden_core::services::jwt_blacklist_service::NoopActiveTokenIterator;
        let id = Uuid::new_v4();
        let mut row = fresh_remote(id, "https://a.tld/users/alice");
        row.disabled_at = Some(Utc::now());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();

        let observer = CapturingDeleteObserver::default();
        let iterator = NoopActiveTokenIterator;
        let outcome = FediverseSignalService::apply_delete_actor_with_hooks(
            &db,
            "https://a.tld/users/alice",
            DeleteActorHooks {
                observer: Some(&observer),
                iterator: Some(&iterator),
            },
        )
        .await
        .unwrap();
        assert_eq!(outcome.rows_disabled, 0);
        assert_eq!(outcome.jwts_blacklisted, 0);
        assert!(observer.calls.lock().unwrap().is_empty());
    }

    #[tokio::test]
    async fn noop_active_token_iterator_returns_empty_list() {
        use rustwarden_core::services::jwt_blacklist_service::{
            ActiveTokenIterator, NoopActiveTokenIterator,
        };
        let v = NoopActiveTokenIterator
            .active_tokens_for_remote(Uuid::new_v4())
            .await
            .unwrap();
        assert!(v.is_empty());
    }

    // ─── fetch_and_cache_remote_actor (F2 hardening) ────────────────

    /// Build a syntactically valid AP Person doc with the given overrides.
    /// `extra` is merged at the top level — pass `null` for « no extra ».
    fn ap_person_json(
        actor_url: &str,
        username: &str,
        with_public_key: bool,
        with_inbox: bool,
    ) -> serde_json::Value {
        let mut doc = serde_json::json!({
            "@context": ["https://www.w3.org/ns/activitystreams"],
            "type": "Person",
            "id": actor_url,
            "preferredUsername": username,
            "name": username,
        });
        if with_public_key {
            doc["publicKey"] = serde_json::json!({
                "id": format!("{actor_url}#main-key"),
                "owner": actor_url,
                "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----\n",
            });
        }
        if with_inbox {
            doc["inbox"] = serde_json::json!(format!("{actor_url}/inbox"));
        }
        doc
    }

    fn testbed_http_client() -> reqwest::Client {
        reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(2))
            .build()
            .unwrap()
    }

    #[tokio::test]
    async fn fetch_inserts_new_remote_identity_on_first_sight() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/alice", server.uri());
        Mock::given(method("GET"))
            .and(path("/users/alice"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(ap_person_json(&actor_url, "alice", true, true)),
            )
            .mount(&server)
            .await;

        let inserted = fresh_remote(Uuid::new_v4(), &actor_url);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<remote_identity::Model>::new()]) // SELECT empty
            .append_query_results([vec![inserted.clone()]]) // INSERT RETURNING
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();

        let model = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect("happy path INSERT must succeed");
        assert_eq!(model.ap_id, inserted.ap_id);
    }

    #[tokio::test]
    async fn fetch_updates_existing_remote_identity_on_refetch() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/bob", server.uri());
        Mock::given(method("GET"))
            .and(path("/users/bob"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(ap_person_json(&actor_url, "bob", true, true)),
            )
            .mount(&server)
            .await;

        let existing = fresh_remote(Uuid::new_v4(), &actor_url);
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![existing.clone()]]) // SELECT hit
            .append_query_results([vec![existing.clone()]]) // UPDATE RETURNING
            .append_exec_results([MockExecResult {
                last_insert_id: 0,
                rows_affected: 1,
            }])
            .into_connection();

        fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect("happy path UPDATE must succeed");
    }

    #[tokio::test]
    async fn fetch_returns_error_on_http_404() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/ghost", server.uri());
        Mock::given(method("GET"))
            .and(path("/users/ghost"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect_err("404 must be surfaced as Validation");
        assert!(format!("{err}").contains("404"), "got: {err}");
    }

    #[tokio::test]
    async fn fetch_returns_error_on_malformed_json() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/junk", server.uri());
        Mock::given(method("GET"))
            .and(path("/users/junk"))
            .respond_with(ResponseTemplate::new(200).set_body_string("not-json-at-all"))
            .mount(&server)
            .await;

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect_err("malformed JSON must be rejected");
        assert!(format!("{err}").contains("not valid JSON"), "got: {err}");
    }

    #[tokio::test]
    async fn fetch_returns_error_when_public_key_missing() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/nokey", server.uri());
        Mock::given(method("GET"))
            .and(path("/users/nokey"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(ap_person_json(&actor_url, "nokey", false, true)),
            )
            .mount(&server)
            .await;

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect_err("missing publicKey must be rejected");
        assert!(format!("{err}").contains("publicKey"), "got: {err}");
    }

    #[tokio::test]
    async fn fetch_returns_error_when_inbox_missing() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/noinbox", server.uri());
        Mock::given(method("GET"))
            .and(path("/users/noinbox"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(ap_person_json(&actor_url, "noinbox", true, false)),
            )
            .mount(&server)
            .await;

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect_err("missing inbox must be rejected");
        assert!(format!("{err}").contains("inbox"), "got: {err}");
    }

    #[tokio::test]
    async fn fetch_returns_error_on_invalid_url() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err =
            fetch_and_cache_remote_actor(&db, &testbed_http_client(), "not a valid url", true)
                .await
                .expect_err("malformed URL must be rejected before HTTP");
        assert!(format!("{err}").contains("not a valid URL"), "got: {err}");
    }

    #[tokio::test]
    async fn fetch_returns_error_when_body_exceeds_cap() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/huge", server.uri());
        // Send a payload just over our cap. wiremock sets a truthful
        // Content-Length, so the pre-flight check trips before we
        // allocate the full body in our process.
        let payload = vec![b'x'; (MAX_ACTOR_BODY_BYTES as usize) + 1];
        Mock::given(method("GET"))
            .and(path("/users/huge"))
            .respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
            .mount(&server)
            .await;

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
            .await
            .expect_err("oversized body must be rejected");
        assert!(format!("{err}").contains("too large"), "got: {err}");
    }

    #[tokio::test]
    async fn ssrf_guard_rejects_loopback_when_strict() {
        // Use a known-loopback URL (wiremock binds 127.0.0.1, so the
        // server starts but the guard must reject before HTTP fires).
        use wiremock::MockServer;
        let server = MockServer::start().await;
        let actor_url = format!("{}/users/anything", server.uri());

        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let err = fetch_and_cache_remote_actor(
            &db,
            &testbed_http_client(),
            &actor_url,
            /* allow_private_ips */ false,
        )
        .await
        .expect_err("loopback must be refused when allow_private_ips=false");
        let msg = format!("{err}");
        assert!(
            msg.contains("loopback") || msg.contains("private") || msg.contains("link-local"),
            "expected SSRF guard rejection, got: {err}"
        );
    }

    // ─── RFC 0028 / Lot 7b — observer emission points ──────────────

    use std::sync::atomic::{AtomicUsize, Ordering};

    #[derive(Default)]
    struct CountingObserver {
        ssrf: AtomicUsize,
        inbox_abuse_reason: std::sync::Mutex<Vec<String>>,
    }

    #[async_trait]
    impl FediverseEventObserver for CountingObserver {
        async fn on_fetch_ssrf(&self, _host: &str, _ip: Option<&str>) {
            self.ssrf.fetch_add(1, Ordering::SeqCst);
        }
        async fn on_inbox_abuse(&self, _domain: &str, _actor: Option<&str>, reason: &str) {
            self.inbox_abuse_reason
                .lock()
                .unwrap()
                .push(reason.to_string());
        }
    }

    #[tokio::test]
    async fn fetch_emits_ssrf_event_on_loopback_rejection() {
        use wiremock::MockServer;
        let server = MockServer::start().await;
        let actor_url = format!("{}/users/anything", server.uri());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let observer = CountingObserver::default();
        let err = fetch_and_cache_remote_actor_observed(
            &db,
            &testbed_http_client(),
            &actor_url,
            /* allow_private_ips */ false,
            &observer,
        )
        .await
        .expect_err("loopback must reject");
        assert!(matches!(err, AppError::Validation(_)));
        assert_eq!(observer.ssrf.load(Ordering::SeqCst), 1);
    }

    #[tokio::test]
    async fn fetch_emits_actor_impersonation_event_on_id_mismatch() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;
        let actor_url = format!("{}/users/alice", server.uri());
        // The document claims a different `id` — CVE-2024-23832 parity.
        let mut doc = ap_person_json(&actor_url, "alice", true, true);
        doc["id"] = serde_json::json!("https://victim.tld/users/alice");
        Mock::given(method("GET"))
            .and(path("/users/alice"))
            .respond_with(ResponseTemplate::new(200).set_body_json(doc))
            .mount(&server)
            .await;
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let observer = CountingObserver::default();
        let err = fetch_and_cache_remote_actor_observed(
            &db,
            &testbed_http_client(),
            &actor_url,
            true,
            &observer,
        )
        .await
        .expect_err("id mismatch must reject");
        assert!(format!("{err}").contains("mismatch"), "got {err}");
        let reasons = observer.inbox_abuse_reason.lock().unwrap().clone();
        assert_eq!(reasons, vec!["actor_impersonation".to_string()]);
    }
}