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() ); }
GitRust