auth_routes.rs 750 lignes · 28917 octets
//! HTTP routes for the AP-native authentication flow (RFC 0013).
//!
//! Five thin handlers around [`crate::services::fediverse_auth_service::FediverseAuthService`]:
//!
//! | Path | Verb | Caller side |
//! |---|---|---|
//! | `/api/v1/auth/login/fediverse/initiate` | POST | client (`b.tld`) |
//! | `/api/v1/auth/login/fediverse/status/{id}` | GET | client |
//! | `/api/v1/auth/login/fediverse/consume/{id}` | POST | client |
//! | `/fediverse/auth/inbox` | POST | IdP (`a.tld`) |
//! | `/api/v1/fediverse/auth/consent` | POST | IdP |
//!

use std::sync::Arc;
use std::time::Duration;

use axum::{
    Extension, Json, Router,
    extract::Path,
    http::{HeaderMap, StatusCode, header},
    routing::{get, post},
};
use sea_orm::DatabaseConnection;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::error::FediverseError;
use super::state::FediverseState;
use crate::observer::{FediverseEventObserver, NoopFediverseObserver};
#[cfg(test)]
use crate::services::fediverse_auth_service::ChallengeStatus;
use crate::services::fediverse_auth_service::{
    ChallengeDecision, FediverseAuthService, InitiateRequest,
};
use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx};
use rustwarden_core::auth::jwt::Claims;
use rustwarden_core::config::auth::AuthConfig;

/// Per-router state injected into the auth-flow handlers.
#[derive(Clone)]
pub struct AuthFlowState {
    /// Same `AuthConfig` as the local login flow — used to mint the
    /// remote JWT on `consume`.
    pub auth_config: Arc<AuthConfig>,
    /// Pluggable signature verifier — used by `auth/inbox` to gate
    /// inbound `AuthRequest` activities.
    pub verifier: Arc<dyn SignatureVerifier>,
    /// TTL of every freshly-initiated challenge.
    pub challenge_ttl: Duration,
}

/// `POST /api/v1/auth/login/fediverse/initiate` body.
#[derive(Debug, Deserialize)]
pub struct InitiateBody {
    /// `@user@domain` handle the user typed.
    pub handle: String,
    /// Optional URL to redirect to after successful consume.
    pub redirect_url: Option<String>,
}

/// `POST /api/v1/auth/login/fediverse/initiate` 201 response.
#[derive(Debug, Serialize)]
pub struct InitiateResponse {
    /// Stable challenge identifier.
    pub challenge_id: Uuid,
    /// Cryptographic nonce embedded in the AP `AuthRequest` activity.
    pub nonce: String,
    /// RFC3339 expiry timestamp.
    pub expires_at: String,
}

/// `GET /api/v1/auth/login/fediverse/status/{id}` 200 response.
#[derive(Debug, Serialize)]
pub struct StatusResponse {
    /// Same identifier as the request.
    pub challenge_id: Uuid,
    /// Wire form (`pending`, `approved`, `rejected`, `expired`, `consumed`).
    pub status: String,
}

/// `POST /api/v1/auth/login/fediverse/consume/{id}` 200 response.
#[derive(Debug, Serialize)]
pub struct ConsumeResponse {
    /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`).
    pub jwt: String,
    /// FK of the federated identity the JWT speaks for.
    pub remote_identity_id: Uuid,
    /// `@user@domain` handle for log/audit cohesion.
    pub handle: String,
}

/// `POST /api/v1/fediverse/auth/consent` body.
#[derive(Debug, Deserialize)]
pub struct ConsentBody {
    /// Challenge being decided on.
    pub challenge_id: Uuid,
    /// `"approve"` or `"reject"`.
    pub decision: String,
    /// On approve only: which `remote_identities.id` is bound to the
    /// IdP's user that clicked approve. The handler trusts this value
    /// because the request itself is authenticated as the IdP user
    /// (CSRF-protected by the consumer middleware).
    pub remote_identity_id: Option<Uuid>,
}

/// Validate a user-supplied redirect URL against the local instance
/// domain (ANSSI F-NET-2).
///
/// Accepts only `http://` or `https://` schemes and rejects any host
/// that does not match `allowed_host` (case-insensitive). This blocks
/// open-redirect attacks (`javascript:`, `data:`, cross-domain
/// phishing) before the value is persisted into
/// `fediverse_auth_challenges.redirect_url`.
fn validate_redirect_url(url_str: &str, allowed_host: &str) -> Result<(), FediverseError> {
    let parsed = url::Url::parse(url_str)
        .map_err(|e| FediverseError::BadRequest(format!("invalid redirect_url: {e}")))?;
    match parsed.scheme() {
        "http" | "https" => {}
        other => {
            return Err(FediverseError::BadRequest(format!(
                "redirect_url scheme {other:?} not allowed (use http or https)"
            )));
        }
    }
    let host = parsed.host_str().unwrap_or("").to_ascii_lowercase();
    if host != allowed_host.to_ascii_lowercase() {
        return Err(FediverseError::BadRequest(format!(
            "redirect_url host {host:?} differs from this instance domain"
        )));
    }
    Ok(())
}

// ─── Handlers ────────────────────────────────────────────────────────

async fn initiate(
    Extension(state): Extension<AuthFlowState>,
    Extension(fediverse_state): Extension<FediverseState>,
    Extension(db): Extension<DatabaseConnection>,
    observer: Option<Extension<Arc<dyn FediverseEventObserver>>>,
    Json(body): Json<InitiateBody>,
) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> {
    let observer: Arc<dyn FediverseEventObserver> = match observer {
        Some(Extension(obs)) => obs,
        None => Arc::new(NoopFediverseObserver),
    };

    // ANSSI F-NET-2 — reject arbitrary `javascript:`, `data:`, or
    // cross-domain redirect URLs before they hit the database. The
    // accepted form is `http(s)://<this instance domain>/...`.
    if let Some(redirect) = body.redirect_url.as_deref() {
        validate_redirect_url(redirect, fediverse_state.config.domain())?;
    }
    let handle = body.handle.clone();
    let challenge = FediverseAuthService::initiate(
        &db,
        InitiateRequest {
            handle: body.handle,
            redirect_url: body.redirect_url,
        },
        state.challenge_ttl,
    )
    .await?;
    // RFC 0028 — feed every accepted auth-challenge initiation to the
    // guard so the auth-spam detector can count high-volume bursts from
    // the same target domain (e.g. credential-stuffing the IdP).
    let domain = handle
        .rsplit_once('@')
        .map(|(_, d)| d.to_ascii_lowercase())
        .unwrap_or_default();
    observer.on_auth_challenge(&domain, &handle).await;
    Ok((
        StatusCode::CREATED,
        Json(InitiateResponse {
            challenge_id: challenge.id,
            nonce: challenge.nonce,
            expires_at: challenge.expires_at.to_rfc3339(),
        }),
    ))
}

async fn status(
    Extension(_state): Extension<AuthFlowState>,
    Extension(db): Extension<DatabaseConnection>,
    Path(challenge_id): Path<Uuid>,
) -> Result<Json<StatusResponse>, FediverseError> {
    let model = FediverseAuthService::status(&db, challenge_id).await?;
    Ok(Json(StatusResponse {
        challenge_id: model.id,
        status: model.status,
    }))
}

async fn consume(
    Extension(state): Extension<AuthFlowState>,
    Extension(db): Extension<DatabaseConnection>,
    Path(challenge_id): Path<Uuid>,
) -> Result<Json<ConsumeResponse>, FediverseError> {
    let session = FediverseAuthService::consume(&db, &state.auth_config, challenge_id).await?;
    Ok(Json(ConsumeResponse {
        jwt: session.jwt,
        remote_identity_id: session.remote_identity_id,
        handle: session.handle,
    }))
}

async fn auth_inbox(
    Extension(state): Extension<AuthFlowState>,
    Extension(db): Extension<DatabaseConnection>,
    headers: HeaderMap,
    body: axum::body::Bytes,
) -> Result<StatusCode, FediverseError> {
    let signature = headers
        .get(header::HeaderName::from_static("signature"))
        .and_then(|v| v.to_str().ok())
        .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".into()))?;

    // Parse the body once — we need both the actor (for verification)
    // and the full payload (for `persist_inbound_request` below).
    let body_json = serde_json::from_slice::<serde_json::Value>(&body)
        .map_err(|e| FediverseError::BadRequest(format!("malformed AuthRequest JSON: {e}")))?;
    let actor = body_json
        .get("actor")
        .and_then(|v| v.as_str())
        .map(str::to_string)
        .unwrap_or_default();

    let _ = signature; // presence already enforced above

    // F8 / RFC 0027 — enforce trust policy BEFORE signature verification
    // (and before any actor fetch). A hostile but signature-valid
    // instance must be rejected here based on its domain alone.
    let source_domain = url::Url::parse(&actor)
        .ok()
        .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase()))
        .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?;
    crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone())
        .assert_domain_allowed(&source_domain)
        .await
        .map_err(|e| FediverseError::Forbidden(e.to_string()))?;

    state
        .verifier
        .verify(&VerifyCtx {
            actor_ap_id: &actor,
            method: "post",
            path: "/fediverse/auth/inbox",
            headers: &headers,
            body: &body,
            public_key_pem: "",
        })
        .await
        .map_err(|e| FediverseError::Unauthorized(e.to_string()))?;

    // Dispatch on the activity `type` (RFC 0025).
    //
    // - `AuthRequest`  → IdP side: persist a Pending challenge tied to
    //   the requester (RFC 0006 Phase 2).
    // - `Accept`/`Reject` (with `object.type == AuthRequest`) → SP side:
    //   propagate the IdP's decision into the local challenge (RFC 0025).
    // - Anything else → AP §7 says ignore unknown types silently; we
    //   return 202 and log to ease debugging.
    let activity_type = body_json
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();

    match activity_type.as_str() {
        "AuthRequest" => {
            // Resolve this IdP's canonical domain from the env (set by
            // the federation router builder at app boot) for the target-validation
            // check. Strip scheme/trailing-slash so the comparison against
            // `target`'s bare host part inside `persist_inbound_request`
            // works whether FEDIVERSE_DOMAIN was set as `example.org` or
            // `http://example.org`. (TODO: thread the parsed
            // FediverseConfig in via extension to avoid env reads here.)
            let idp_domain = std::env::var("FEDIVERSE_DOMAIN")
                .unwrap_or_default()
                .trim_start_matches("http://")
                .trim_start_matches("https://")
                .trim_end_matches('/')
                .to_ascii_lowercase();
            FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain)
                .await
                .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
        }
        "Accept" | "Reject" => {
            FediverseAuthService::record_inbound_decision(&db, &body_json)
                .await
                .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
        }
        other => {
            tracing::debug!(
                activity_type = other,
                "auth_inbox: ignoring unknown activity type"
            );
        }
    }

    Ok(StatusCode::ACCEPTED)
}

async fn consent(
    Extension(_state): Extension<AuthFlowState>,
    Extension(claims): Extension<Claims>,
    Extension(db): Extension<DatabaseConnection>,
    Json(body): Json<ConsentBody>,
) -> Result<StatusCode, FediverseError> {
    // ANSSI F-NET-4 — require an authenticated Bearer JWT on this
    // JSON-API endpoint. Cookie-only callers cannot reach it, which
    // structurally eliminates the CSRF surface on this consent flow
    // (the UI counterpart in rustwarden-fediverse-ui already enforces
    // CSRF via `csrf::validate_csrf_token`).
    tracing::info!(
        user_id = %claims.sub,
        challenge_id = %body.challenge_id,
        decision = %body.decision,
        "fediverse consent recorded (api)",
    );
    let decision = match body.decision.as_str() {
        "approve" => {
            let remote = body.remote_identity_id.ok_or_else(|| {
                FediverseError::BadRequest("approve requires remote_identity_id".into())
            })?;
            ChallengeDecision::Approve {
                remote_identity_id: remote,
            }
        }
        "reject" => ChallengeDecision::Reject,
        other => {
            return Err(FediverseError::BadRequest(format!(
                "invalid decision '{other}'",
            )));
        }
    };
    FediverseAuthService::record_decision(&db, body.challenge_id, decision).await?;
    Ok(StatusCode::OK)
}

/// Build the auth-flow router. Mounted by
/// [`crate::routes::router_with`] alongside the discovery
/// + lifecycle inbox routes.
pub fn router(state: AuthFlowState) -> Router<DatabaseConnection> {
    Router::new()
        .route("/api/v1/auth/login/fediverse/initiate", post(initiate))
        .route(
            "/api/v1/auth/login/fediverse/status/{challenge_id}",
            get(status),
        )
        .route(
            "/api/v1/auth/login/fediverse/consume/{challenge_id}",
            post(consume),
        )
        .route("/fediverse/auth/inbox", post(auth_inbox))
        .route("/api/v1/fediverse/auth/consent", post(consent))
        .layer(Extension(state))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::fediverse_auth_challenge;
    use async_trait::async_trait;
    use axum::body::Body;
    use axum::http::Request;
    use chrono::{Duration as ChronoDuration, Utc};
    use rustwarden_core::error::AppError;
    use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
    use tower::ServiceExt;

    /// F8 enforcement helper for unit tests: pre-queues three mock
    /// results (app_settings.fediverse_enabled=true,
    /// fediverse_trust_mode=open_with_blocklist, empty blocklist) so
    /// `FediverseTrustService::assert_domain_allowed` answers Ok for
    /// any well-formed actor URL.
    fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase {
        use crate::models::fediverse_blocked_domain;
        use rustwarden_core::models::app_settings;
        let now = Utc::now();
        mock.append_query_results([vec![app_settings::Model {
            id: Uuid::new_v4(),
            key: "fediverse_enabled".to_string(),
            value: "true".to_string(),
            description: None,
            updated_by: None,
            updated_at: now,
        }]])
        .append_query_results([vec![app_settings::Model {
            id: Uuid::new_v4(),
            key: "fediverse_trust_mode".to_string(),
            value: "open_with_blocklist".to_string(),
            description: None,
            updated_by: None,
            updated_at: now,
        }]])
        .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()])
    }

    fn test_auth_config() -> AuthConfig {
        AuthConfig {
            jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
                .to_string(),
            jwt_expiration_minutes: 15,
            jwt_issuer: "rustwarden-test".to_string(),
            refresh_token_expiration_days: 7,
            remember_me_expiration_days: 30,
            session_timeout_minutes: 30,
            session_backend: rustwarden_core::config::auth::SessionBackend::Memory,
            redis_url: None,
            rate_limit_login_per_minute: 5,
            rate_limit_refresh_per_minute: 10,
            rate_limit_general_per_minute: 100,
            cookie_secure: false,
            cookie_same_site: "Strict".to_string(),
            login_path: "/login".to_string(),
            auth_failure_mode: rustwarden_core::config::auth::AuthFailureMode::Redirect,
        }
    }

    struct AlwaysAccept;

    #[async_trait]
    impl SignatureVerifier for AlwaysAccept {
        async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> {
            Ok(())
        }
    }

    /// Stub `LocalUserResolver` returning `Ok(None)` for every lookup
    /// — sufficient for the auth_routes tests that never resolve a
    /// local user (initiate / status / consume operate on challenges
    /// keyed by remote identity).
    struct EmptyResolver;

    #[async_trait]
    impl crate::resolver::LocalUserResolver for EmptyResolver {
        async fn find_local_user(
            &self,
            _username: &str,
        ) -> Result<Option<crate::resolver::UserSummary>, AppError> {
            Ok(None)
        }
        async fn count_users_total(&self) -> Result<u64, AppError> {
            Ok(0)
        }
        async fn count_users_active_since(&self, _days: u32) -> Result<u64, AppError> {
            Ok(0)
        }
        async fn count_local_posts(&self) -> Result<u64, AppError> {
            Ok(0)
        }
    }

    /// Build a stub `Claims` to satisfy handlers that extract one (the
    /// `consent` route in particular). Auth middleware doesn't run in
    /// unit tests, so we synthesise the extension via a layer below.
    fn test_claims() -> Claims {
        Claims {
            sub: Uuid::new_v4(),
            jti: String::new(),
            aud: String::new(),
            username: "tester".into(),
            roles: vec!["admin".into()],
            permissions: vec![],
            exp: 0,
            iat: 0,
            iss: "rustwarden-test".into(),
            identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local,
            remote_id: None,
        }
    }

    fn build_router(db: DatabaseConnection) -> Router {
        let state = AuthFlowState {
            auth_config: Arc::new(test_auth_config()),
            verifier: Arc::new(AlwaysAccept),
            challenge_ttl: Duration::from_secs(300),
        };
        let fediverse_state = FediverseState {
            config: crate::config::FediverseConfig::for_tests("test.local"),
            resolver: Arc::new(EmptyResolver),
        };
        router(state)
            .layer(Extension(db))
            .layer(Extension(fediverse_state))
            .layer(Extension(test_claims()))
            .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection())
    }

    fn fresh_challenge(id: Uuid) -> fediverse_auth_challenge::Model {
        let now = Utc::now();
        fediverse_auth_challenge::Model {
            id,
            nonce: "nonce-xxx".into(),
            handle: "alice@a.tld".into(),
            domain: "a.tld".into(),
            requested_remote_id: None,
            status: ChallengeStatus::Pending.as_str().into(),
            redirect_url: None,
            created_at: now,
            expires_at: now + ChronoDuration::minutes(5),
            consumed_at: None,
        }
    }

    fn req_post(uri: &str, body: &str) -> Request<Body> {
        Request::builder()
            .uri(uri)
            .method("POST")
            .header("content-type", "application/json")
            .body(Body::from(body.to_string()))
            .unwrap()
    }

    fn req_get(uri: &str) -> Request<Body> {
        Request::builder()
            .uri(uri)
            .method("GET")
            .body(Body::empty())
            .unwrap()
    }

    // ─── initiate ────────────────────────────────────────────────────

    #[tokio::test]
    async fn initiate_returns_201_with_challenge_id() {
        let row = fresh_challenge(Uuid::new_v4());
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![row]])
            .into_connection();
        let body = serde_json::json!({
            "handle": "alice@a.tld",
        })
        .to_string();
        let resp = build_router(db)
            .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::CREATED);
    }

    #[tokio::test]
    async fn initiate_returns_400_for_invalid_handle() {
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let body = serde_json::json!({
            "handle": "no-at-sign",
        })
        .to_string();
        let resp = build_router(db)
            .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    // ─── status ──────────────────────────────────────────────────────

    #[tokio::test]
    async fn status_returns_pending_for_fresh_challenge() {
        let id = Uuid::new_v4();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![fresh_challenge(id)]])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_get(&format!(
                "/api/v1/auth/login/fediverse/status/{id}"
            )))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn status_returns_404_for_unknown_id() {
        let id = Uuid::new_v4();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([Vec::<fediverse_auth_challenge::Model>::new()])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_get(&format!(
                "/api/v1/auth/login/fediverse/status/{id}"
            )))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }

    // ─── consume ─────────────────────────────────────────────────────

    #[tokio::test]
    async fn consume_returns_jwt_for_approved_challenge() {
        let id = Uuid::new_v4();
        let remote_id = Uuid::new_v4();
        let mut row = fresh_challenge(id);
        row.status = ChallengeStatus::Approved.as_str().into();
        row.requested_remote_id = Some(remote_id);
        let mut after = row.clone();
        after.status = ChallengeStatus::Consumed.as_str().into();
        after.consumed_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 resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/auth/login/fediverse/consume/{id}"),
                "",
            ))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn consume_returns_400_when_pending() {
        let id = Uuid::new_v4();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![fresh_challenge(id)]])
            .into_connection();
        let resp = build_router(db)
            .oneshot(req_post(
                &format!("/api/v1/auth/login/fediverse/consume/{id}"),
                "",
            ))
            .await
            .unwrap();
        // Validation error from the service maps to 400 via AppError.
        assert!(
            resp.status() == StatusCode::BAD_REQUEST
                || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
            "expected 400/422, got {}",
            resp.status()
        );
    }

    // ─── auth_inbox ──────────────────────────────────────────────────

    #[tokio::test]
    async fn auth_inbox_returns_400_when_requester_actor_unknown() {
        // Phase 2 (RFC 0006) enriches `auth_inbox` to persist a
        // challenge bound to the requester's `remote_identities.id`.
        // When the row is absent the service returns Validation, which
        // maps to a 400 BadRequest — NOT a 202.
        let body = serde_json::json!({
            "type": "AuthRequest",
            "actor": "https://unknown.tld",
            "target": "alice@a.tld",
            "nonce": "n",
        })
        .to_string();
        let req = Request::builder()
            .uri("/fediverse/auth/inbox")
            .method("POST")
            .header("signature", "keyId=...")
            .header("content-type", "application/activity+json")
            .body(Body::from(body))
            .unwrap();
        // Empty remote_identities cache → persist_inbound_request rejects.
        // F8 needs permissive trust mock prepended so enforcement passes
        // and the BadRequest assertion catches the persist_inbound_request
        // path, not the trust gate.
        //
        // We deliberately do NOT mutate FEDIVERSE_DOMAIN here: race
        // condition risk with concurrent build_*_payload tests that read
        // env via FediverseConfig::from_env(). Whatever value is in env
        // (or none), the assertion still holds: either the target domain
        // mismatches and persist_inbound_request returns BadRequest, or
        // the remote_identities lookup misses and also returns BadRequest.
        let db: DatabaseConnection =
            permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres))
                .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
                .into_connection();
        let resp = build_router(db).oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn auth_inbox_returns_401_when_signature_missing() {
        let req = Request::builder()
            .uri("/fediverse/auth/inbox")
            .method("POST")
            .body(Body::from("{}"))
            .unwrap();
        let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
        let resp = build_router(db).oneshot(req).await.unwrap();
        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    }

    // ─── consent ─────────────────────────────────────────────────────

    #[tokio::test]
    async fn consent_approve_marks_challenge_approved() {
        let id = Uuid::new_v4();
        let remote_id = Uuid::new_v4();
        let row = fresh_challenge(id);
        let mut after = row.clone();
        after.status = ChallengeStatus::Approved.as_str().into();
        after.requested_remote_id = Some(remote_id);
        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 body = serde_json::json!({
            "challenge_id": id,
            "decision": "approve",
            "remote_identity_id": remote_id,
        })
        .to_string();
        let resp = build_router(db)
            .oneshot(req_post("/api/v1/fediverse/auth/consent", &body))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn consent_reject_marks_challenge_rejected() {
        let id = Uuid::new_v4();
        let row = fresh_challenge(id);
        let mut after = row.clone();
        after.status = ChallengeStatus::Rejected.as_str().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 body = serde_json::json!({
            "challenge_id": id,
            "decision": "reject",
        })
        .to_string();
        let resp = build_router(db)
            .oneshot(req_post("/api/v1/fediverse/auth/consent", &body))
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
    }
}