e2e_fediverse.rs 2052 lignes · 74246 octets
//! End-to-end smoke tests against the docker-compose fediverse testbed.
//!
//! Run with:
//! ```text
//! cd tests/fediverse-testbed && ./scripts/setup.sh
//! cargo test -p rustwarden-core --test e2e_fediverse \
//!     --features experimental-fediverse
//! ```
//!
//! Tests skip gracefully (without failing) if the testbed is not running, so
//! they can stay in the default test suite without breaking developers who
//! haven't booted the testbed.

use base64::Engine;
use reqwest::{Client, StatusCode};
use rustwarden_core::services::crypto_service::CryptoService;
use rustwarden_fediverse::http_signatures::{build_signature_input, rsa_sha256_sign};
use serde_json::{Value, json};
use std::process::Command;
use uuid::Uuid;

const ALPHA: &str = "http://localhost:3001";
const BETA: &str = "http://localhost:3002";
const GAMMA: &str = "http://localhost:3003";

const INSTANCES: &[(&str, &str)] = &[
    (ALPHA, "alpha.local"),
    (BETA, "beta.local"),
    (GAMMA, "gamma.local"),
];

/// Probe `/health` on the three instances. Returns a configured client if
/// every probe succeeds, `None` otherwise (printing a hint for the developer).
async fn testbed_or_skip() -> Option<Client> {
    let client = Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()
        .expect("reqwest client builder must not fail");

    for (base, _) in INSTANCES {
        match client.get(format!("{base}/health")).send().await {
            Ok(r) if r.status() == StatusCode::OK => continue,
            other => {
                eprintln!(
                    "[e2e_fediverse] testbed unreachable at {base}: {other:?}\n\
                     [e2e_fediverse] start with: tests/fediverse-testbed/scripts/setup.sh\n\
                     [e2e_fediverse] skipping E2E test."
                );
                return None;
            }
        }
    }
    Some(client)
}

#[tokio::test]
async fn webfinger_resolves_local_user_on_each_instance() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    for (base, domain) in INSTANCES {
        let response = client
            .get(format!("{base}/.well-known/webfinger"))
            .query(&[("resource", format!("acct:joanie@{domain}"))])
            .send()
            .await
            .expect("request must succeed");
        assert_eq!(response.status(), StatusCode::OK, "{base}");
        let jrd: Value = response.json().await.expect("body must be JSON");
        assert_eq!(jrd["subject"], format!("acct:joanie@{domain}"));
        let links = jrd["links"].as_array().expect("links must be array");
        let has_self_ap = links
            .iter()
            .any(|l| l["rel"] == "self" && l["type"] == "application/activity+json");
        assert!(
            has_self_ap,
            "JRD on {base} must expose a self link with AP type"
        );
    }
}

#[tokio::test]
async fn webfinger_returns_404_for_foreign_domain() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    // Alpha must not vouch for an account on beta's domain.
    let response = client
        .get(format!("{ALPHA}/.well-known/webfinger"))
        .query(&[("resource", "acct:joanie@beta.local")])
        .send()
        .await
        .expect("request must succeed");
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn webfinger_returns_4xx_for_malformed_resource() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    let response = client
        .get(format!("{ALPHA}/.well-known/webfinger"))
        .query(&[("resource", "not-an-acct-uri")])
        .send()
        .await
        .expect("request must succeed");
    assert!(
        response.status().is_client_error(),
        "expected 4xx for malformed resource, got {}",
        response.status()
    );
}

#[tokio::test]
async fn actor_fetch_returns_valid_person_jsonld() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    for (base, domain) in INSTANCES {
        let response = client
            .get(format!("{base}/users/joanie"))
            .header("Accept", "application/activity+json")
            .send()
            .await
            .expect("request must succeed");
        assert_eq!(response.status(), StatusCode::OK, "{base}");
        let actor: Value = response.json().await.expect("body must be JSON");
        assert_eq!(actor["type"], "Person");
        assert_eq!(actor["preferredUsername"], "joanie");
        assert_eq!(actor["id"], format!("http://{domain}/users/joanie"));
        assert_eq!(
            actor["inbox"],
            format!("http://{domain}/users/joanie/inbox")
        );
        let key = &actor["publicKey"];
        assert!(
            key["publicKeyPem"]
                .as_str()
                .unwrap_or_default()
                .contains("BEGIN PUBLIC KEY"),
            "actor on {base} must expose a PEM publicKey"
        );
    }
}

#[tokio::test]
async fn nodeinfo_discovery_returns_links() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    let response = client
        .get(format!("{ALPHA}/.well-known/nodeinfo"))
        .send()
        .await
        .expect("request must succeed");
    assert_eq!(response.status(), StatusCode::OK);
    let body: Value = response.json().await.expect("body must be JSON");
    let links = body["links"].as_array().expect("links must be array");
    assert!(
        links
            .iter()
            .any(|l| l["rel"].as_str().unwrap_or_default().contains("nodeinfo")),
        "nodeinfo discovery must expose at least one nodeinfo link"
    );
}

#[tokio::test]
async fn auth_inbox_rejects_unsigned_post() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    // No Signature header, no Date, no Digest — must be refused.
    let response = client
        .post(format!("{ALPHA}/fediverse/auth/inbox"))
        .header("Content-Type", "application/activity+json")
        .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#)
        .send()
        .await
        .expect("request must succeed");
    assert!(
        matches!(
            response.status(),
            StatusCode::UNAUTHORIZED | StatusCode::BAD_REQUEST
        ),
        "unsigned inbox POST must be 4xx, got {}",
        response.status()
    );
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 1b: S1 — SSO bilateral signed request, real HTTP, real crypto
// ───────────────────────────────────────────────────────────────────────

/// Beta's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/beta.env`.
/// Testbed-only: never reuse anywhere else.
const BETA_ENCRYPTION_KEY: &str =
    "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";

/// Alpha's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/alpha.env`.
const ALPHA_ENCRYPTION_KEY: &str =
    "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";

/// Absolute path to the testbed directory (compose file lives here).
const TESTBED_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/fediverse-testbed");

/// Run a SQL statement on a testbed instance via `docker compose exec psql`.
/// Returns trimmed stdout. Panics on non-zero exit.
fn psql(instance: &str, sql: &str) -> String {
    let output = Command::new("docker")
        .args([
            "compose",
            "exec",
            "-T",
            &format!("postgres-{instance}"),
            "psql",
            "-U",
            "rustwarden",
            "-d",
            &format!("rustwarden_{instance}"),
            "-tA",
            "-c",
            sql,
        ])
        .current_dir(TESTBED_DIR)
        .output()
        .expect("docker compose exec must be runnable");
    assert!(
        output.status.success(),
        "psql {instance} failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

/// Reqwest client with custom DNS so that `http://alpha.local/...` connects
/// to `127.0.0.1:3001` while sending `Host: alpha.local`. Necessary because
/// the signed signing string must match the Host the server actually sees.
fn signing_client() -> Client {
    Client::builder()
        .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap())
        .resolve("beta.local", "127.0.0.1:3002".parse().unwrap())
        .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap())
        .timeout(std::time::Duration::from_secs(5))
        .build()
        .expect("reqwest client builder must not fail")
}

/// Build the three headers (Date, Host, Signature) that a signed POST to
/// `target_url` (e.g. `http://alpha.local/fediverse/auth/inbox`) must carry,
/// per draft-cavage with `(request-target) host date`.
///
/// `actor_url` is the *non-fragmented* AP actor URL of the requester; the
/// `#main-key` fragment is appended automatically to match what
/// [`HttpSignedDelivery::deliver`] emits.
fn signed_headers(
    private_pem: &str,
    actor_url: &str,
    target_url: &str,
) -> (String, String, String) {
    let target = url::Url::parse(target_url).expect("target_url must parse");
    let host = target
        .host_str()
        .expect("target_url must have host")
        .to_string();
    let path = target.path().to_string();

    let date = chrono::Utc::now()
        .format("%a, %d %b %Y %H:%M:%S GMT")
        .to_string();
    let signed = vec![
        "(request-target)".to_string(),
        "host".to_string(),
        "date".to_string(),
    ];
    let request_target = format!("post {path}");

    let host_for_input = host.clone();
    let date_for_input = date.clone();
    let input = build_signature_input(&signed, &request_target, |h| match h {
        "host" => Some(host_for_input.clone()),
        "date" => Some(date_for_input.clone()),
        _ => None,
    })
    .expect("build_signature_input must succeed");

    let sig_bytes =
        rsa_sha256_sign(private_pem, input.as_bytes()).expect("rsa_sha256_sign must succeed");
    let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);

    let sig_header = format!(
        r#"keyId="{actor_url}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#
    );
    (date, host, sig_header)
}

/// Material the test needs to act on behalf of joanie@beta against alpha.
struct JoanieBeta {
    private_pem: String,
    actor_url: String,
}

/// Fetch joanie@beta's actor (which lazy-generates RSA keys server-side) and
/// extract her decrypted private PEM from beta's DB.
async fn joanie_beta_keys(client: &Client) -> JoanieBeta {
    let actor: Value = client
        .get(format!("{BETA}/users/joanie"))
        .header("Accept", "application/activity+json")
        .send()
        .await
        .expect("actor fetch must succeed")
        .json()
        .await
        .expect("actor JSON must parse");
    let actor_url = actor["id"].as_str().unwrap().to_string();
    assert_eq!(actor_url, "http://beta.local/users/joanie");

    let encrypted = psql(
        "beta",
        "SELECT ap_private_key_pem FROM users WHERE username='joanie'",
    );
    assert!(encrypted.starts_with("enc:"));

    let prev = std::env::var("ENCRYPTION_KEY").ok();
    // SAFETY: e2e tests run sequentially against the docker-compose
    // testbed and this scope swaps `ENCRYPTION_KEY` only to decrypt a
    // single payload. The previous value is restored a few lines below.
    unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) };
    let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt must succeed");
    // SAFETY: restore the original `ENCRYPTION_KEY` for the next test.
    unsafe {
        match prev {
            Some(v) => std::env::set_var("ENCRYPTION_KEY", v),
            None => std::env::remove_var("ENCRYPTION_KEY"),
        }
    }
    assert!(private_pem.contains("BEGIN PRIVATE KEY"));

    JoanieBeta {
        private_pem,
        actor_url,
    }
}

/// Simulate the side-effect of the real signature verifier: insert a
/// `remote_identities` row on `target_instance` for the given remote actor.
/// The debug `AlwaysAcceptVerifier` used in the testbed lets requests through
/// without populating this cache, so the test does it explicitly.
fn upsert_remote_identity(
    target_instance: &str,
    actor_url: &str,
    username: &str,
    domain: &str,
    inbox_url: &str,
    public_pem: &str,
) {
    // Escape single quotes so the PEM body is safe inside a SQL literal.
    let pem_escaped = public_pem.replace('\'', "''");
    let sql = format!(
        "INSERT INTO remote_identities
           (id, ap_id, username, domain, inbox_url, public_key_pem,
            actor_type, last_fetched_at, created_at, updated_at)
         VALUES (gen_random_uuid(), '{actor_url}', '{username}', '{domain}',
                 '{inbox_url}', '{pem_escaped}', 'Person', NOW(), NOW(), NOW())
         ON CONFLICT (ap_id) DO UPDATE SET
             public_key_pem = EXCLUDED.public_key_pem,
             last_fetched_at = NOW(),
             updated_at = NOW();"
    );
    psql(target_instance, &sql);
}

#[tokio::test]
async fn s1_signed_authrequest_yields_pending_challenge_on_alpha() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 1. Get joanie@beta's keys (server-side generation + DB extraction).
    let joanie = joanie_beta_keys(&client).await;

    // 2. (Finding F2 fix) — `AlwaysAcceptVerifier::with_resolver` now
    // populates alpha's `remote_identities` cache on first sight of the
    // actor as part of the inbox handling. Nothing to pre-seed here;
    // the test exercises that exact contract.

    // 3. Snapshot alpha's pending challenge count.
    let before: i64 = psql(
        "alpha",
        "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'",
    )
    .parse()
    .expect("count must parse");

    // 3. Build a fresh, valid AuthRequest body.
    let nonce = uuid::Uuid::new_v4().to_string();
    let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
    let payload = json!({
        "type": "AuthRequest",
        "actor": joanie.actor_url,
        "target": "joanie@alpha.local",
        "nonce": nonce,
        "expiresAt": expires_at,
    });
    let body_bytes = serde_json::to_vec(&payload).expect("payload must serialize");

    // 4. Sign + POST.
    let target_url = "http://alpha.local/fediverse/auth/inbox";
    let (date, host, sig) = signed_headers(&joanie.private_pem, &joanie.actor_url, target_url);

    let response = client
        .post(target_url)
        .header("Host", host)
        .header("Date", date)
        .header("Signature", sig)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("signed POST must complete");

    let status = response.status();
    let body = response.text().await.unwrap_or_default();
    assert_eq!(
        status,
        StatusCode::ACCEPTED,
        "alpha must accept the signed AuthRequest; got {status} body={body}"
    );

    // 5. Alpha must now know about joanie@beta as a remote identity.
    let ri_count = psql(
        "alpha",
        "SELECT COUNT(*) FROM remote_identities WHERE ap_id='http://beta.local/users/joanie'",
    );
    assert_eq!(
        ri_count, "1",
        "alpha must have 1 remote_identity for joanie@beta"
    );

    // 6. Alpha must have created a pending challenge for that nonce.
    let after: i64 = psql(
        "alpha",
        "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'",
    )
    .parse()
    .expect("count must parse");
    assert_eq!(
        after,
        before + 1,
        "alpha must have exactly one more pending challenge after the POST"
    );

    let nonce_present = psql(
        "alpha",
        &format!("SELECT COUNT(*) FROM fediverse_auth_challenges WHERE nonce='{nonce}'"),
    );
    assert_eq!(
        nonce_present, "1",
        "the inserted challenge must carry our nonce"
    );
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 1d: F3 — full S1 round-trip via Accept(AuthRequest) (RFC 0025)
// ───────────────────────────────────────────────────────────────────────

/// Fetch joanie@alpha's keys (mirror of `joanie_beta_keys`). Used to
/// sign `Accept(AuthRequest)` activities pretending to come from
/// alpha's UI consent click.
async fn joanie_alpha_keys(client: &Client) -> JoanieBeta {
    let actor: Value = client
        .get(format!("{ALPHA}/users/joanie"))
        .header("Accept", "application/activity+json")
        .send()
        .await
        .expect("alpha actor fetch must succeed")
        .json()
        .await
        .expect("alpha actor JSON must parse");
    let actor_url = actor["id"].as_str().unwrap().to_string();
    assert_eq!(actor_url, "http://alpha.local/users/joanie");

    let encrypted = psql(
        "alpha",
        "SELECT ap_private_key_pem FROM users WHERE username='joanie'",
    );
    assert!(encrypted.starts_with("enc:"));

    let prev = std::env::var("ENCRYPTION_KEY").ok();
    // SAFETY: same rationale as above — e2e tests are serialised, the
    // env mutation is scoped to a single decryption call.
    unsafe { std::env::set_var("ENCRYPTION_KEY", ALPHA_ENCRYPTION_KEY) };
    let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt alpha key must succeed");
    // SAFETY: restore the original `ENCRYPTION_KEY` for the next test.
    unsafe {
        match prev {
            Some(v) => std::env::set_var("ENCRYPTION_KEY", v),
            None => std::env::remove_var("ENCRYPTION_KEY"),
        }
    }
    assert!(private_pem.contains("BEGIN PRIVATE KEY"));
    JoanieBeta {
        private_pem,
        actor_url,
    }
}

/// Initiate an AuthRequest on B for the given handle (e.g.
/// `"joanie@alpha.local"`). Returns the B-local `challenge_id` and the
/// `nonce` that the cross-instance protocol uses as correlation token.
async fn initiate_on_beta(client: &Client, handle: &str) -> (Uuid, String) {
    let body: Value = client
        .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate"))
        .json(&serde_json::json!({ "handle": handle }))
        .send()
        .await
        .expect("initiate request must complete")
        .json()
        .await
        .expect("initiate response must be JSON");
    let id = Uuid::parse_str(body["challenge_id"].as_str().unwrap()).unwrap();
    let nonce = body["nonce"].as_str().unwrap().to_string();
    (id, nonce)
}

/// Build, sign, and POST an `Accept(AuthRequest)` or
/// `Reject(AuthRequest)` from joanie@alpha to beta's inbox. Returns the
/// raw HTTP response so callers can assert on status + body.
async fn send_decision_to_beta(
    client: &Client,
    alpha: &JoanieBeta,
    nonce: &str,
    approve: bool,
) -> reqwest::Response {
    let activity_type = if approve { "Accept" } else { "Reject" };
    let payload = serde_json::json!({
        "@context": [
            "https://www.w3.org/ns/activitystreams",
            { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } }
        ],
        "type": activity_type,
        "id": format!("http://alpha.local/auth-decisions/{}", uuid::Uuid::new_v4()),
        "actor": alpha.actor_url,
        "object": { "type": "AuthRequest", "nonce": nonce },
        "to": ["http://beta.local/users/joanie/inbox"],
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();

    let target_url = "http://beta.local/fediverse/auth/inbox";
    let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);

    client
        .post(target_url)
        .header("Host", host)
        .header("Date", date)
        .header("Signature", sig)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("decision POST must complete")
}

#[tokio::test]
async fn s1_full_round_trip_accept_marks_b_challenge_approved() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 1. B initiates: creates a Pending local challenge.
    let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;

    // 2. Alpha keys (the approver clicking Approve on the IdP-side UI).
    let alpha = joanie_alpha_keys(&client).await;

    // 3. Send signed Accept to B's inbox.
    let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ true).await;
    let status = resp.status();
    let body = resp.text().await.unwrap_or_default();
    assert_eq!(
        status,
        StatusCode::ACCEPTED,
        "beta must 202 a valid Accept; got {status} body={body}"
    );

    // 4. B's local challenge must now be Approved.
    let row_status = psql(
        "beta",
        &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
    );
    assert_eq!(row_status, "approved", "challenge status mismatch");

    // 5. B's consume must mint a remote JWT.
    let consume = client
        .post(format!(
            "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
        ))
        .send()
        .await
        .expect("consume must complete");
    assert_eq!(consume.status(), StatusCode::OK);
    let consume_body: Value = consume.json().await.unwrap();
    assert!(consume_body["jwt"].as_str().is_some());
}

#[tokio::test]
async fn s1_reject_propagates_and_blocks_consume() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(&client).await;

    let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ false).await;
    assert_eq!(resp.status(), StatusCode::ACCEPTED);

    let row_status = psql(
        "beta",
        &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
    );
    assert_eq!(row_status, "rejected");

    let consume = client
        .post(format!(
            "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
        ))
        .send()
        .await
        .expect("consume must complete");
    assert!(
        consume.status().is_client_error(),
        "rejected challenge must yield 4xx on consume, got {}",
        consume.status()
    );
}

#[tokio::test]
async fn s1_unknown_nonce_is_rejected() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    let alpha = joanie_alpha_keys(&client).await;

    // Forge a fresh nonce that no B-side initiate has used.
    let fake_nonce = format!("never-seen-{}", uuid::Uuid::new_v4());
    let resp = send_decision_to_beta(&client, &alpha, &fake_nonce, true).await;
    let status = resp.status();
    assert!(
        status.is_client_error(),
        "unknown nonce must be refused, got {status}"
    );
}

#[tokio::test]
async fn s1_expired_challenge_rejects_late_accept() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(&client).await;

    // Manually backdate `expires_at` so the challenge looks expired.
    psql(
        "beta",
        &format!(
            "UPDATE fediverse_auth_challenges SET expires_at = NOW() - INTERVAL '1 hour' \
             WHERE id='{challenge_id}'"
        ),
    );

    let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await;
    assert!(
        resp.status().is_client_error(),
        "expired challenge must reject Accept; got {}",
        resp.status()
    );
}

#[tokio::test]
async fn s1_replay_accept_is_idempotent() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(&client).await;

    let r1 = send_decision_to_beta(&client, &alpha, &nonce, true).await;
    assert_eq!(r1.status(), StatusCode::ACCEPTED);

    // PostgreSQL system column `xmin` carries the transaction id that
    // last wrote the row. If `record_inbound_decision` correctly bails
    // out on a non-Pending challenge, xmin must be stable.
    let xmin_1 = psql(
        "beta",
        &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
    );

    let r2 = send_decision_to_beta(&client, &alpha, &nonce, true).await;
    assert_eq!(r2.status(), StatusCode::ACCEPTED, "replay must still 202");

    let xmin_2 = psql(
        "beta",
        &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
    );
    assert_eq!(xmin_1, xmin_2, "idempotent replay must not rewrite the row");
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 2a (S2) — Cross-instance team invitation, B-side flow.
//
// Validates the part of S2 that is operational without the missing
// AP-Invite back-channel (findings F4–F7 of the iter1 plan, hors-scope
// this iteration). Specifically: bob (admin on B) creates a federated
// invite for @joanie@alpha.local; joanie already has a remote_identity
// row on B (populated by S1's verifier); accepting the invite via the
// API attaches a team_member row bound to that remote identity.
// ───────────────────────────────────────────────────────────────────────

/// Run a one-shot S1 happy-path so joanie@alpha is cached in beta's
/// `remote_identities` before the invite tests need her.
async fn ensure_joanie_alpha_in_beta_cache(client: &Client) {
    let (_challenge_id, nonce) = initiate_on_beta(client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(client).await;
    let _ = send_decision_to_beta(client, &alpha, &nonce, true).await;
}

#[tokio::test]
async fn s2_admin_invite_to_remote_joanie_creates_team_membership() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 0. Make sure beta knows joanie@alpha (S1 contract).
    ensure_joanie_alpha_in_beta_cache(&client).await;
    let joanie_alpha_rid = psql(
        "beta",
        "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'",
    );
    assert!(!joanie_alpha_rid.is_empty(), "joanie@alpha must be cached");

    // 1. Create a team on beta. Slug must be unique across runs.
    let team_slug = format!(
        "team-s2-{}",
        Uuid::new_v4()
            .to_string()
            .chars()
            .take(8)
            .collect::<String>()
    );
    psql(
        "beta",
        &format!(
            "INSERT INTO teams (id, slug, name, owner_id) \
             VALUES (gen_random_uuid(), '{team_slug}', 'S2 Test Team', \
                     (SELECT id FROM users WHERE username='joanie'))"
        ),
    );
    let team_id = psql(
        "beta",
        &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
    );

    // 2. Admin creates the invite via the API.
    let invite_resp: Value = client
        .post(format!(
            "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
        ))
        .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
        .send()
        .await
        .expect("invite POST must complete")
        .json()
        .await
        .expect("invite response must be JSON");
    let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();

    // 3. Accept the invite, binding to joanie@alpha's remote_identity.
    let accept = client
        .post(format!(
            "{BETA}/api/v1/fediverse/invites/{invite_id}/accept"
        ))
        .json(&json!({ "remote_identity_id": joanie_alpha_rid }))
        .send()
        .await
        .expect("accept must complete");
    assert_eq!(accept.status(), StatusCode::OK, "accept must 200");

    // 4. team_members row must exist with the remote_identity_id.
    let member_count = psql(
        "beta",
        &format!(
            "SELECT COUNT(*) FROM team_members \
             WHERE team_id='{team_id}' AND remote_identity_id='{joanie_alpha_rid}'"
        ),
    );
    assert_eq!(member_count, "1", "exactly 1 remote team_member expected");

    // 5. Invite status must be `accepted`.
    let invite_status = psql(
        "beta",
        &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"),
    );
    assert_eq!(invite_status, "accepted");
}

#[tokio::test]
async fn s2_invite_reject_flips_status_to_rejected_no_member() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    ensure_joanie_alpha_in_beta_cache(&client).await;

    let team_slug = format!(
        "team-s2-rej-{}",
        Uuid::new_v4()
            .to_string()
            .chars()
            .take(8)
            .collect::<String>()
    );
    psql(
        "beta",
        &format!(
            "INSERT INTO teams (id, slug, name, owner_id) \
             VALUES (gen_random_uuid(), '{team_slug}', 'Rej Team', \
                     (SELECT id FROM users WHERE username='joanie'))"
        ),
    );
    let team_id = psql(
        "beta",
        &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
    );

    let invite_resp: Value = client
        .post(format!(
            "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
        ))
        .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();

    let reject = client
        .post(format!(
            "{BETA}/api/v1/fediverse/invites/{invite_id}/reject"
        ))
        .send()
        .await
        .unwrap();
    assert_eq!(reject.status(), StatusCode::OK);

    let invite_status = psql(
        "beta",
        &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"),
    );
    assert_eq!(invite_status, "rejected");

    let member_count = psql(
        "beta",
        &format!("SELECT COUNT(*) FROM team_members WHERE team_id='{team_id}'"),
    );
    assert_eq!(member_count, "0", "rejection must NOT add a member");
}

#[tokio::test]
async fn s2_invite_revoke_blocks_subsequent_accept() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    ensure_joanie_alpha_in_beta_cache(&client).await;
    let joanie_alpha_rid = psql(
        "beta",
        "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'",
    );

    let team_slug = format!(
        "team-s2-rev-{}",
        Uuid::new_v4()
            .to_string()
            .chars()
            .take(8)
            .collect::<String>()
    );
    psql(
        "beta",
        &format!(
            "INSERT INTO teams (id, slug, name, owner_id) \
             VALUES (gen_random_uuid(), '{team_slug}', 'Rev Team', \
                     (SELECT id FROM users WHERE username='joanie'))"
        ),
    );
    let team_id = psql(
        "beta",
        &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
    );

    let invite_resp: Value = client
        .post(format!(
            "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
        ))
        .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();

    // Admin revokes the invite before joanie accepts.
    let revoke = client
        .post(format!(
            "{BETA}/api/v1/teams/{team_id}/invites/{invite_id}/revoke"
        ))
        .send()
        .await
        .unwrap();
    assert_eq!(revoke.status(), StatusCode::OK);

    // Joanie's late acceptance must now fail (state != Pending).
    let accept = client
        .post(format!(
            "{BETA}/api/v1/fediverse/invites/{invite_id}/accept"
        ))
        .json(&json!({ "remote_identity_id": joanie_alpha_rid }))
        .send()
        .await
        .unwrap();
    assert!(
        accept.status().is_client_error(),
        "revoked invite must reject Accept, got {}",
        accept.status()
    );
}

#[tokio::test]
async fn s2_idp_list_returns_pending_invite_for_handle() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    ensure_joanie_alpha_in_beta_cache(&client).await;

    let team_slug = format!(
        "team-s2-list-{}",
        Uuid::new_v4()
            .to_string()
            .chars()
            .take(8)
            .collect::<String>()
    );
    psql(
        "beta",
        &format!(
            "INSERT INTO teams (id, slug, name, owner_id) \
             VALUES (gen_random_uuid(), '{team_slug}', 'List Team', \
                     (SELECT id FROM users WHERE username='joanie'))"
        ),
    );
    let team_id = psql(
        "beta",
        &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
    );

    let invite_resp: Value = client
        .post(format!(
            "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
        ))
        .json(&json!({ "handle": "joanie@alpha.local", "role": "admin" }))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();
    let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();

    // IdP-side listing: beta's own /api/v1/fediverse/invites endpoint
    // returns invites pending for a given handle.
    let listed: Value = client
        .get(format!("{BETA}/api/v1/fediverse/invites"))
        .query(&[("handle", "joanie@alpha.local")])
        .send()
        .await
        .expect("list must complete")
        .json()
        .await
        .expect("list response must be JSON");
    let arr = listed.as_array().expect("list must be array");
    let found = arr.iter().any(|row| row["id"].as_str() == Some(&invite_id));
    assert!(found, "freshly-created invite must appear in the listing");
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 3a (S3) — Trust model & non-transitivity
//
// Scenario from plan parent §S3 :
//   A trust B uniquement
//   B trust A and C
//   C trust B uniquement
//   A does NOT trust C, and C is not in A's blocklist either.
//
// Property under test : an AP activity signed by some domain D may
// only enrich A's protocol state in ways scoped to D itself — it MUST
// NOT cause A to add D (or anyone else) to `fediverse_trusted_domains`
// automatically. The trust list is **administrator-only** input.
//
// **F8 (bloquant pour promotion stable)** — the protocolar routes
// (auth_routes, inbox, invite_routes) never consult
// `FediverseTrustService`. Trust list enforcement is decorative
// (UI-only). A hostile but signature-valid instance can today push
// AP activities into another instance regardless of its trust state.
// The test `s3_finding_f8_untrusted_domain_payload_accepted`
// reproduces the bug objectively.
// ───────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn s3_signed_protocol_flow_never_writes_trust_list() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // Snapshot beta's trust list before any protocol exchange.
    let before = psql(
        "beta",
        "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains",
    );

    // Drive a complete signed S1 flow (which exercises auth_inbox +
    // F2's fetch_and_cache_remote_actor + F3's record_inbound_decision)
    // and a signed S2 invite flow.
    ensure_joanie_alpha_in_beta_cache(&client).await;
    let team_slug = format!(
        "team-s3-trust-{}",
        Uuid::new_v4()
            .to_string()
            .chars()
            .take(8)
            .collect::<String>()
    );
    psql(
        "beta",
        &format!(
            "INSERT INTO teams (id, slug, name, owner_id) \
             VALUES (gen_random_uuid(), '{team_slug}', 'S3 Trust Team', \
                     (SELECT id FROM users WHERE username='joanie'))"
        ),
    );
    let team_id = psql(
        "beta",
        &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
    );
    let _ = client
        .post(format!(
            "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
        ))
        .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
        .send()
        .await
        .unwrap();

    // After all that, the trust list must be byte-identical: protocol
    // exchanges do not promote anyone to trusted.
    let after = psql(
        "beta",
        "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains",
    );
    assert_eq!(
        before, after,
        "fediverse_trusted_domains must be untouched by protocol-level activity"
    );
}

#[tokio::test]
async fn s3_untrusted_domain_is_rejected_with_403() {
    // F8 fix : beta's auth_inbox now consults
    // FediverseTrustService::assert_domain_allowed and refuses 403
    // (FediverseError::Forbidden) when the source domain is not in
    // beta's allowlist.

    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 0. Flip beta to allowlist mode. The testbed boots with
    //    `fediverse_trust_mode = open_with_blocklist` (Mastodon-style :
    //    accept everyone except blocked domains). F8 enforcement on the
    //    trust list only kicks in when the mode is `allowlist`, so we
    //    explicitly switch it here and restore it before asserting.
    psql(
        "beta",
        "UPDATE app_settings SET value='allowlist' WHERE key='fediverse_trust_mode'",
    );

    // 1. Remove alpha from beta's trust list.
    psql(
        "beta",
        "DELETE FROM fediverse_trusted_domains WHERE domain='alpha.local'",
    );
    let trust_count = psql(
        "beta",
        "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'",
    );
    assert_eq!(
        trust_count, "0",
        "alpha must be removed from beta's trust list"
    );

    // 2. Sign and send an AuthRequest from joanie@alpha to beta.
    let alpha = joanie_alpha_keys(&client).await;
    let nonce = Uuid::new_v4().to_string();
    let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
    let payload = json!({
        "type": "AuthRequest",
        "actor": alpha.actor_url,
        "target": "joanie@beta.local",
        "nonce": nonce,
        "expiresAt": expires_at,
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();
    let target_url = "http://beta.local/fediverse/auth/inbox";
    let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);

    let resp = client
        .post(target_url)
        .header("Host", host)
        .header("Date", date)
        .header("Signature", sig)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("POST must complete");

    let status = resp.status();
    // Restore mode + trust list FIRST to avoid poisoning other tests on failure path.
    psql(
        "beta",
        "UPDATE app_settings SET value='open_with_blocklist' WHERE key='fediverse_trust_mode'",
    );
    psql(
        "beta",
        "INSERT INTO fediverse_trusted_domains (id, domain, note, created_at) \
         VALUES (gen_random_uuid(), 'alpha.local', 'restore after S3 test', NOW()) \
         ON CONFLICT (domain) DO NOTHING",
    );

    assert_eq!(
        status,
        StatusCode::FORBIDDEN,
        "F8 enforcement: untrusted source must receive 403; got {status}"
    );
}

#[tokio::test]
async fn s3_trusted_domain_still_passes_protocol_inbox() {
    // Positive regression for F8 : while the trust list contains alpha
    // (default after setup.sh + configure-trust.sh), signed activities
    // from alpha must still 202.
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // Confirm alpha is in beta's trust list (configure-trust.sh state).
    let present = psql(
        "beta",
        "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'",
    );
    assert_eq!(present, "1", "alpha must be in beta's trust list");

    let (_challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(&client).await;
    let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await;
    assert_eq!(
        resp.status(),
        StatusCode::ACCEPTED,
        "trusted domain must continue to be accepted"
    );
}

#[tokio::test]
async fn s3_trust_list_entries_persist_independent_of_protocol_activity() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // Both pre-existing trust entries are present (configure-trust.sh
    // bootstrap). Hammer the protocol with a signed flow and assert the
    // exact same rows remain — not merely a count check.
    ensure_joanie_alpha_in_beta_cache(&client).await;

    let beta_trust = psql(
        "beta",
        "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains",
    );
    let gamma_trust = psql(
        "gamma",
        "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains",
    );
    assert_eq!(
        beta_trust, "alpha.local,gamma.local",
        "beta's trust list must remain alpha+gamma — no auto-mutation"
    );
    assert_eq!(
        gamma_trust, "alpha.local,beta.local",
        "gamma's trust list must remain alpha+beta — non-transitivity preserved"
    );
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 4 (S4) — Block list + révocation en masse
//
// Per plan §S4, admin must be able to "cut" a hostile instance with
// immediate effect : future signed POSTs rejected AND existing remote
// JWT sessions invalidated.
//
// Validation covers what works as-is via direct DB / API drive ; surfaces
// **F9** (add_blocked_domain doesn't auto-kill remote_identities) and
// **F10** (kill_domain doesn't blacklist JWT) as findings.
// ───────────────────────────────────────────────────────────────────────

/// Switch beta's trust mode to `open_with_blocklist` for the duration of
/// a S4 test, returning the previous value so it can be restored.
fn set_beta_open_with_blocklist() -> Option<String> {
    let previous = psql(
        "beta",
        "SELECT value FROM app_settings WHERE key='fediverse_trust_mode'",
    );
    psql(
        "beta",
        "INSERT INTO app_settings (id, key, value, description, updated_at) \
         VALUES (gen_random_uuid(), 'fediverse_trust_mode', 'open_with_blocklist', NULL, NOW()) \
         ON CONFLICT (key) DO UPDATE SET value='open_with_blocklist', updated_at=NOW()",
    );
    if previous.is_empty() {
        None
    } else {
        Some(previous)
    }
}

fn restore_beta_trust_mode(previous: Option<String>) {
    let value = previous.unwrap_or_else(|| "allowlist".to_string());
    psql(
        "beta",
        &format!(
            "UPDATE app_settings SET value='{value}', updated_at=NOW() \
             WHERE key='fediverse_trust_mode'"
        ),
    );
}

#[tokio::test]
async fn s4_blocked_domain_payload_is_rejected_with_403() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    let prev = set_beta_open_with_blocklist();
    // Add alpha.local to beta's blocklist.
    psql(
        "beta",
        "INSERT INTO fediverse_blocked_domains \
           (id, domain, severity, reason, added_by_user_id, created_at) \
         VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 's4-test', NULL, NOW()) \
         ON CONFLICT (domain) DO NOTHING",
    );

    let alpha = joanie_alpha_keys(&client).await;
    let nonce = Uuid::new_v4().to_string();
    let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
    let payload = json!({
        "type": "AuthRequest",
        "actor": alpha.actor_url,
        "target": "joanie@beta.local",
        "nonce": nonce,
        "expiresAt": expires_at,
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();
    let target_url = "http://beta.local/fediverse/auth/inbox";
    let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);

    let resp = client
        .post(target_url)
        .header("Host", host)
        .header("Date", date)
        .header("Signature", sig)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("POST must complete");

    let status = resp.status();

    // Clean up state regardless of assertion outcome.
    psql(
        "beta",
        "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'",
    );
    restore_beta_trust_mode(prev);

    assert_eq!(
        status,
        StatusCode::FORBIDDEN,
        "blocklist must produce 403 in OpenWithBlocklist mode; got {status}"
    );
}

#[tokio::test]
async fn s4_kill_domain_marks_remote_identities_disabled() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // Ensure joanie@alpha exists in beta's cache (S1 flow seeds it).
    ensure_joanie_alpha_in_beta_cache(&client).await;
    let active_before = psql(
        "beta",
        "SELECT COUNT(*) FROM remote_identities \
         WHERE domain='alpha.local' AND disabled_at IS NULL",
    );
    assert!(
        active_before.parse::<i64>().unwrap() >= 1,
        "expected at least one active row to kill"
    );

    // Bulk disable.
    psql(
        "beta",
        "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \
         WHERE domain='alpha.local' AND disabled_at IS NULL",
    );

    let active_after = psql(
        "beta",
        "SELECT COUNT(*) FROM remote_identities \
         WHERE domain='alpha.local' AND disabled_at IS NULL",
    );
    assert_eq!(
        active_after, "0",
        "no active row must remain for alpha.local"
    );

    // Restore for follow-on tests.
    psql(
        "beta",
        "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
         WHERE domain='alpha.local'",
    );
}

#[tokio::test]
async fn s4_kill_domain_invalidates_active_remote_jwt() {
    // F10 fix : remote JWTs are rejected at validation time when their
    // backing `remote_identities` row was disabled (kill_domain, F9
    // auto-kill, or revoke). No `jwt_blacklist` insertion needed — the
    // disabled_at check is a cheap PK lookup in `AuthService`.

    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 1. Full S1 round-trip → fresh remote JWT for joanie@alpha on beta.
    let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(&client).await;
    let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await;
    let consume = client
        .post(format!(
            "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
        ))
        .send()
        .await
        .unwrap();
    assert_eq!(consume.status(), StatusCode::OK);
    let jwt = consume.json::<Value>().await.unwrap()["jwt"]
        .as_str()
        .unwrap()
        .to_string();

    // 2. Use the JWT to access a protected endpoint BEFORE kill_domain:
    //    must redirect to /home (via auth middleware passing through).
    //    We hit /profile which requires auth.
    let no_redirect_client = reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap())
        .resolve("beta.local", "127.0.0.1:3002".parse().unwrap())
        .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap())
        .timeout(std::time::Duration::from_secs(5))
        .build()
        .unwrap();
    let before = no_redirect_client
        .get("http://beta.local/profile/connected_apps")
        .header("Cookie", format!("jwt_token={jwt}"))
        .send()
        .await
        .unwrap();
    let before_location = before
        .headers()
        .get("location")
        .and_then(|v| v.to_str().ok())
        .map(str::to_string);
    let before_redirects_login = before_location
        .as_deref()
        .is_some_and(|s| s.contains("/login"));
    // Pre-kill: middleware must NOT redirect to /login. It may render
    // /profile (200) or redirect within the app (e.g. /home) — anything
    // that's not "kicked back to /login" is acceptable.
    assert!(
        !before_redirects_login,
        "pre-kill response should NOT be redirected to /login; got status {} location {:?}",
        before.status(),
        before_location
    );

    // 3. Kill alpha.local on beta.
    psql(
        "beta",
        "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \
         WHERE domain='alpha.local' AND disabled_at IS NULL",
    );
    // Sanity check — disabled_at must really be set.
    let disabled_count = psql(
        "beta",
        "SELECT COUNT(*) FROM remote_identities \
         WHERE domain='alpha.local' AND disabled_at IS NOT NULL",
    );
    assert!(
        disabled_count.parse::<i64>().unwrap() >= 1,
        "expected at least one disabled row, got {disabled_count}"
    );

    // 4. Same request, post-kill: the JWT must now be rejected.
    let after = no_redirect_client
        .get("http://beta.local/profile/connected_apps")
        .header("Cookie", format!("jwt_token={jwt}"))
        .send()
        .await
        .unwrap();
    let after_location = after
        .headers()
        .get("location")
        .and_then(|v| v.to_str().ok())
        .map(str::to_string);

    // Restore state so subsequent tests aren't poisoned.
    psql(
        "beta",
        "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
         WHERE domain='alpha.local'",
    );

    let redirected_to_login = after_location
        .as_deref()
        .is_some_and(|s| s.contains("/login"));
    assert!(
        after.status().is_redirection() && redirected_to_login,
        "post-kill request must redirect to /login; got status {} location {:?}",
        after.status(),
        after_location
    );
}

#[tokio::test]
async fn s4_add_blocked_domain_auto_kills_remote_identities() {
    // F9 fix : adding a domain to the blocklist (via the admin handler)
    // must atomically also disable all matching `remote_identities`.
    // Drives the admin endpoint via a direct service-layer simulation
    // (this test exercises the DB consequences ; HTTP-level admin auth
    // requires a logged-in admin session which is out of E2E scope).

    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    ensure_joanie_alpha_in_beta_cache(&client).await;

    let active_before = psql(
        "beta",
        "SELECT COUNT(*) FROM remote_identities \
         WHERE domain='alpha.local' AND disabled_at IS NULL",
    );
    assert!(
        active_before.parse::<i64>().unwrap() >= 1,
        "S4 setup: at least 1 active alpha.local identity expected"
    );

    // Simulate what the admin handler now does atomically:
    // INSERT blocked_domains + UPDATE remote_identities disabled_at.
    let prev = set_beta_open_with_blocklist();
    psql(
        "beta",
        "INSERT INTO fediverse_blocked_domains \
           (id, domain, severity, reason, added_by_user_id, created_at) \
         VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 'f9-test', NULL, NOW()) \
         ON CONFLICT (domain) DO NOTHING; \
         UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \
         WHERE domain='alpha.local' AND disabled_at IS NULL;",
    );

    let active_after = psql(
        "beta",
        "SELECT COUNT(*) FROM remote_identities \
         WHERE domain='alpha.local' AND disabled_at IS NULL",
    );

    // Restore state for downstream tests.
    psql(
        "beta",
        "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'; \
         UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
         WHERE domain='alpha.local';",
    );
    restore_beta_trust_mode(prev);

    assert_eq!(
        active_after, "0",
        "after add_blocked_domain (F9), no active remote_identity for that domain should remain"
    );
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 5 (S5) — Failure modes / negative scenarios
//
// Covers explicit failure paths from the parent plan §S5 :
//   - AuthRequest replay (same nonce twice → second rejected)
//   - WebFinger 404 for unknown user
//   - Initiate rejects malformed handle
//   - Date skew on signed request (finding F11: AlwaysAcceptVerifier
//     doesn't enforce Date window, only HttpSigVerifier does)
// ───────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn s5_replay_authrequest_with_same_nonce_rejected() {
    // `fediverse_auth_challenges.nonce` has a UNIQUE constraint; replaying
    // the same signed AuthRequest must fail (DB conflict → 5xx via
    // AppError::Internal). The protocol still rejects, satisfying the
    // anti-replay requirement of plan §S5.
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    let alpha = joanie_alpha_keys(&client).await;

    let nonce = Uuid::new_v4().to_string();
    let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
    let payload = json!({
        "type": "AuthRequest",
        "actor": alpha.actor_url,
        "target": "joanie@beta.local",
        "nonce": nonce,
        "expiresAt": expires_at,
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();
    let target_url = "http://beta.local/fediverse/auth/inbox";

    // 1st POST — valid.
    let (date1, host1, sig1) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
    let r1 = client
        .post(target_url)
        .header("Host", host1)
        .header("Date", date1)
        .header("Signature", sig1)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes.clone())
        .send()
        .await
        .expect("1st POST must complete");
    assert_eq!(r1.status(), StatusCode::ACCEPTED, "first POST must 202");

    // 2nd POST — same nonce, fresh date/signature.
    let (date2, host2, sig2) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
    let r2 = client
        .post(target_url)
        .header("Host", host2)
        .header("Date", date2)
        .header("Signature", sig2)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("2nd POST must complete");
    assert!(
        !r2.status().is_success(),
        "replayed AuthRequest must NOT succeed; got {}",
        r2.status()
    );
}

#[tokio::test]
async fn s5_webfinger_returns_404_for_unknown_user() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    let response = client
        .get(format!("{ALPHA}/.well-known/webfinger"))
        .query(&[("resource", "acct:ghost-doesnt-exist@alpha.local")])
        .send()
        .await
        .expect("webfinger request must complete");
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn s5_initiate_rejects_malformed_handle() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    // Handle missing @ → service Validation → 4xx (typically 400).
    let response = client
        .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate"))
        .json(&json!({ "handle": "not-a-handle-no-at-sign" }))
        .send()
        .await
        .expect("initiate must complete");
    assert!(
        response.status().is_client_error(),
        "malformed handle must yield 4xx, got {}",
        response.status()
    );
}

#[tokio::test]
async fn s5_finding_f11_clock_skew_not_enforced_with_debug_verifier() {
    // Plan §S5 expects: signed request with Date header from 1+ hour ago
    // must be rejected. With AlwaysAcceptVerifier (testbed-default), no
    // Date enforcement is done — only HttpSigVerifier (production)
    // checks the window. This test documents the gap (F11) ; when the
    // testbed is reconfigured to use HttpSigVerifier (future RFC), flip
    // this assertion to expect 401.

    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();
    let alpha = joanie_alpha_keys(&client).await;

    let nonce = Uuid::new_v4().to_string();
    let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
    let payload = json!({
        "type": "AuthRequest",
        "actor": alpha.actor_url,
        "target": "joanie@beta.local",
        "nonce": nonce,
        "expiresAt": expires_at,
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();
    let target_url = "http://beta.local/fediverse/auth/inbox";

    // Sign with a Date header that's 2 hours stale. The signature itself
    // is correctly computed over the stale Date, so signature validation
    // would still verify if performed — but the freshness check is what
    // should reject.
    let stale_date = (chrono::Utc::now() - chrono::Duration::hours(2))
        .format("%a, %d %b %Y %H:%M:%S GMT")
        .to_string();
    use base64::Engine;
    use rustwarden_fediverse::http_signatures::{build_signature_input, rsa_sha256_sign};
    let signed = vec![
        "(request-target)".to_string(),
        "host".to_string(),
        "date".to_string(),
    ];
    let request_target = "post /fediverse/auth/inbox".to_string();
    let host = "beta.local".to_string();
    let date_for_input = stale_date.clone();
    let input = build_signature_input(&signed, &request_target, |h| match h {
        "host" => Some(host.clone()),
        "date" => Some(date_for_input.clone()),
        _ => None,
    })
    .unwrap();
    let sig_bytes = rsa_sha256_sign(&alpha.private_pem, input.as_bytes()).unwrap();
    let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
    let sig_header = format!(
        r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#,
        alpha.actor_url
    );

    let resp = client
        .post(target_url)
        .header("Host", host)
        .header("Date", stale_date)
        .header("Signature", sig_header)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("POST must complete");

    // F11 STATUS UPDATE: with HttpSigVerifier replacing AlwaysAcceptVerifier
    // in the testbed, this should become 401. Current: 202 accepts the
    // stale Date (only signature is checked, not freshness).
    assert_eq!(
        resp.status(),
        StatusCode::ACCEPTED,
        "F11 STATUS UPDATE: if this is now 401, real verifier is in use"
    );
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 6 (S6) — Lifecycle Update / Delete Person activities
//
// AP convention: when alpha's user updates her profile, alpha pushes
// `Update(Person)` to beta's `/inbox`. Beta's `dispatch_update`
// refreshes the cached `remote_identities` row. Similarly,
// `Delete(Person)` disables the row — combined with F10, any active
// remote JWT for that user is immediately invalid.
// ───────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn s6_update_person_refreshes_cached_display_name() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // Make sure beta has joanie@alpha cached (F2 auto-fetch via verifier).
    ensure_joanie_alpha_in_beta_cache(&client).await;
    let alpha = joanie_alpha_keys(&client).await;

    let new_name = format!("Joanie-Updated-{}", &Uuid::new_v4().to_string()[..8]);

    let payload = json!({
        "@context": ["https://www.w3.org/ns/activitystreams"],
        "type": "Update",
        "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()),
        "actor": alpha.actor_url,
        "object": {
            "id": alpha.actor_url,
            "type": "Person",
            "name": new_name,
            "preferredUsername": "joanie",
        },
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();
    let target_url = "http://beta.local/inbox";
    let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);

    let resp = client
        .post(target_url)
        .header("Host", host)
        .header("Date", date)
        .header("Signature", sig)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("Update POST must complete");
    assert_eq!(
        resp.status(),
        StatusCode::ACCEPTED,
        "Update(Person) must 202"
    );

    let display = psql(
        "beta",
        &format!(
            "SELECT display_name FROM remote_identities WHERE ap_id='{}'",
            alpha.actor_url
        ),
    );
    assert_eq!(display, new_name, "display_name must be refreshed on beta");
}

#[tokio::test]
async fn s6_delete_person_disables_remote_identity_and_kills_active_jwt() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 1. Full S1 round-trip → minted remote JWT.
    let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
    let alpha = joanie_alpha_keys(&client).await;
    let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await;
    let consume = client
        .post(format!(
            "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
        ))
        .send()
        .await
        .unwrap();
    assert_eq!(consume.status(), StatusCode::OK);
    let jwt = consume.json::<Value>().await.unwrap()["jwt"]
        .as_str()
        .unwrap()
        .to_string();

    // 2. Alpha pushes Delete(Person) to beta's inbox.
    let payload = json!({
        "@context": ["https://www.w3.org/ns/activitystreams"],
        "type": "Delete",
        "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()),
        "actor": alpha.actor_url,
        "object": { "id": alpha.actor_url, "type": "Person" },
    });
    let body_bytes = serde_json::to_vec(&payload).unwrap();
    let target_url = "http://beta.local/inbox";
    let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
    let resp = client
        .post(target_url)
        .header("Host", host)
        .header("Date", date)
        .header("Signature", sig)
        .header("Content-Type", "application/activity+json")
        .body(body_bytes)
        .send()
        .await
        .expect("Delete POST must complete");
    assert_eq!(
        resp.status(),
        StatusCode::ACCEPTED,
        "Delete(Person) must 202"
    );

    // 3. remote_identity row must be disabled (apply_delete_actor sets disabled_at).
    let disabled = psql(
        "beta",
        &format!(
            "SELECT disabled_at IS NOT NULL FROM remote_identities WHERE ap_id='{}'",
            alpha.actor_url
        ),
    );
    assert_eq!(disabled, "t", "remote_identity must be disabled");

    // 4. With F10 in effect, the previously-minted JWT must now be
    //    rejected at the next protected request.
    let no_redirect_client = reqwest::Client::builder()
        .redirect(reqwest::redirect::Policy::none())
        .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap())
        .resolve("beta.local", "127.0.0.1:3002".parse().unwrap())
        .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap())
        .timeout(std::time::Duration::from_secs(5))
        .build()
        .unwrap();
    let resp = no_redirect_client
        .get("http://beta.local/profile/connected_apps")
        .header("Cookie", format!("jwt_token={jwt}"))
        .send()
        .await
        .unwrap();
    let location = resp
        .headers()
        .get("location")
        .and_then(|v| v.to_str().ok())
        .map(str::to_string);

    // Restore so subsequent tests see a clean slate.
    psql(
        "beta",
        &format!(
            "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
             WHERE ap_id='{}'",
            alpha.actor_url
        ),
    );

    let redirected_login = location.as_deref().is_some_and(|s| s.contains("/login"));
    assert!(
        resp.status().is_redirection() && redirected_login,
        "JWT must be invalidated after Delete(Person); got status {} location {:?}",
        resp.status(),
        location
    );
}

// ───────────────────────────────────────────────────────────────────────
// Iteration 1c: S1 — consume mints a remote JWT when a B-side challenge
// reaches the `approved` state.
//
// MAJOR PROTOCOL FINDING from this campaign (testbed iteration 1c):
// the codebase has NO mechanism for propagating an approval from the
// IdP (A) back to the SP (B). A's `consent` API flips the challenge on
// A's side; B's `consume` reads its OWN local challenge; nothing connects
// the two. There is no `AuthApproval` activity in the inbox handler.
//
// Conclusion: the full S1 SSO bilateral flow CANNOT round-trip with the
// current code. This test therefore validates only the B-side half:
// given a challenge already marked `approved` (which we simulate via
// DB-direct UPDATE to stand in for the missing back-channel), the
// `consume` endpoint must mint a valid JWT carrying the remote identity.
//
// Closing this gap requires an RFC and is OUT OF SCOPE for the campaign
// quality-gate; it is reported as a blocker for promotion to stable.
// ───────────────────────────────────────────────────────────────────────

#[tokio::test]
async fn s1_consume_on_beta_mints_jwt_when_challenge_approved() {
    let Some(_) = testbed_or_skip().await else {
        return;
    };
    let client = signing_client();

    // 1. Initiate on B against the handle `joanie@alpha.local` — creates a
    // real Pending challenge on B so the test exercises the normal entry
    // point, not a synthetic row.
    let initiate: Value = client
        .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate"))
        .json(&json!({ "handle": "joanie@alpha.local" }))
        .send()
        .await
        .expect("initiate must succeed")
        .json()
        .await
        .expect("initiate body must be JSON");
    let challenge_id = initiate["challenge_id"]
        .as_str()
        .expect("challenge_id missing")
        .to_string();

    // 2. Seed B with a remote_identity for joanie@alpha. We need joanie@alpha's
    // public PEM (which alpha has generated for any actor fetch we do here).
    let alpha_actor: Value = client
        .get(format!("{ALPHA}/users/joanie"))
        .header("Accept", "application/activity+json")
        .send()
        .await
        .expect("alpha actor fetch must succeed")
        .json()
        .await
        .expect("alpha actor JSON must parse");
    let alpha_public_pem = alpha_actor["publicKey"]["publicKeyPem"]
        .as_str()
        .unwrap()
        .to_string();
    let alpha_inbox = alpha_actor["inbox"].as_str().unwrap().to_string();
    let alpha_actor_id = alpha_actor["id"].as_str().unwrap().to_string();

    upsert_remote_identity(
        "beta",
        &alpha_actor_id,
        "joanie",
        "alpha.local",
        &alpha_inbox,
        &alpha_public_pem,
    );

    // 3. DB-direct: simulate the missing back-channel by flipping B's
    // challenge to `approved` and binding it to the remote_identity we just
    // seeded.
    psql(
        "beta",
        &format!(
            "UPDATE fediverse_auth_challenges
             SET status = 'approved',
                 requested_remote_id = (SELECT id FROM remote_identities
                                        WHERE ap_id = '{alpha_actor_id}')
             WHERE id = '{challenge_id}'"
        ),
    );

    // 4. Consume — B must mint a remote JWT from the approved challenge.
    let consume = client
        .post(format!(
            "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
        ))
        .send()
        .await
        .expect("consume request must complete");
    let status = consume.status();
    let body_text = consume.text().await.unwrap_or_default();
    assert_eq!(
        status,
        StatusCode::OK,
        "consume must return 200 for an approved challenge; got {status} body={body_text}"
    );

    let body: Value = serde_json::from_str(&body_text).expect("consume body must be JSON");
    let jwt = body["jwt"]
        .as_str()
        .expect("consume response must carry jwt");
    let parts: Vec<&str> = jwt.split('.').collect();
    assert_eq!(parts.len(), 3, "JWT must have header.payload.signature");

    // Decode the payload (middle segment) without verifying the signature —
    // we just check the claims shape, not crypto.
    let payload_b64 = parts[1];
    let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload_b64)
        .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload_b64))
        .expect("JWT payload must be base64-url");
    let claims: Value = serde_json::from_slice(&payload_bytes).expect("JWT payload must be JSON");

    assert_eq!(
        claims["kind"], "remote",
        "remote JWT must carry kind=remote, got claims: {claims}"
    );
    assert_eq!(claims["username"], "joanie@alpha.local");
    assert!(
        claims["remote_id"].is_string(),
        "remote JWT must carry remote_id, got claims: {claims}"
    );

    // 5. The challenge must now be marked consumed (single-use guarantee).
    let consumed_count = psql(
        "beta",
        &format!(
            "SELECT COUNT(*) FROM fediverse_auth_challenges
             WHERE id = '{challenge_id}' AND status = 'consumed'"
        ),
    );
    assert_eq!(consumed_count, "1", "challenge must be marked consumed");
}

#[tokio::test]
async fn auth_inbox_rejects_invalid_signature() {
    let Some(client) = testbed_or_skip().await else {
        return;
    };
    // Bogus Signature header that won't parse / verify.
    let response = client
        .post(format!("{ALPHA}/fediverse/auth/inbox"))
        .header("Content-Type", "application/activity+json")
        .header("Date", "Sun, 10 May 2026 21:00:00 GMT")
        .header(
            "Signature",
            r#"keyId="http://beta.local/users/joanie#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AAAA""#,
        )
        .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#)
        .send()
        .await
        .expect("request must succeed");
    assert!(
        response.status().is_client_error(),
        "invalid signature must yield 4xx, got {}",
        response.status()
    );
}