fediverse_interop.rs
302 lignes · 10802 octets
//! RFC 0020 — Fediverse interoperability tests. //! //! Spawns a `wiremock` HTTP server playing the role of a remote //! ActivityPub inbox (Mastodon-style) and exercises the signing / //! verification chain end-to-end: //! //! 1. `signed_request_round_trip_through_wiremock` — build + sign + //! POST a request, capture it on the receiver side, re-verify the //! signature against the same public PEM via `parse_and_verify_cavage`. //! 2. `outbound_delivers_when_inbox_returns_202` — `drain_one` with a //! delivery impl that returns `Ok` on 2xx flips the row to //! `Delivered`. //! 3. `outbound_defers_when_inbox_returns_503` — the same harness with //! a 503 leaves the row in `Pending` with `next_retry_at` set per //! RFC 0010. use std::sync::Arc; use async_trait::async_trait; use base64::Engine; use chrono::Utc; use reqwest::Client; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; use uuid::Uuid; use wiremock::matchers::{header_exists, method, path}; use wiremock::{Mock, MockServer, Request, ResponseTemplate}; use rustwarden_core::error::AppError; use rustwarden_fediverse::http_signatures::{ build_signature_input, parse_and_verify_cavage, rsa_sha256_sign, }; use rustwarden_fediverse::models::fediverse_outbound_queue; use rustwarden_fediverse::services::actor_key_service::ActorKeyService; use rustwarden_fediverse::services::fediverse_outbound_service::{ FediverseOutboundService, OutboundDelivery, OutboundStatus, }; /// Build the canonical `Signature` header for a draft-cavage signed /// POST. Mirrors `HttpSignedDelivery::deliver` but lives in the test /// module so the round-trip can be exercised without DB plumbing. fn sign_post(private_pem: &str, key_id: &str, host: &str, path: &str, date: &str) -> String { let signed_headers = vec![ "(request-target)".to_string(), "host".to_string(), "date".to_string(), ]; let request_target = format!("post {path}"); let input = build_signature_input(&signed_headers, &request_target, |h| match h { "host" => Some(host.to_string()), "date" => Some(date.to_string()), _ => None, }) .expect("build_signature_input"); let signature_bytes = rsa_sha256_sign(private_pem, input.as_bytes()).expect("sign"); let signature_b64 = base64::engine::general_purpose::STANDARD.encode(&signature_bytes); format!( r#"keyId="{key_id}",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, ) } #[tokio::test] async fn signed_request_round_trip_through_wiremock() { let server = MockServer::start().await; let inbox_path = "/inbox"; Mock::given(method("POST")) .and(path(inbox_path)) .and(header_exists("signature")) .respond_with(ResponseTemplate::new(202)) .expect(1) .mount(&server) .await; let material = ActorKeyService::generate().expect("RSA gen"); let url = format!("{}{inbox_path}", server.uri()); let parsed = reqwest::Url::parse(&url).unwrap(); let host = parsed.host_str().unwrap().to_string(); let host_with_port = match parsed.port() { Some(p) => format!("{host}:{p}"), None => host.clone(), }; let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); let key_id = "https://a.tld/users/alice#main-key"; let signature_header = sign_post( &material.private_pem, key_id, &host_with_port, inbox_path, &date, ); let body = br#"{"@context":"https://www.w3.org/ns/activitystreams","type":"Update"}"#; let response = Client::new() .post(&url) .header("Date", &date) .header("Host", &host_with_port) .header("Signature", &signature_header) .header("Content-Type", "application/activity+json") .body(body.to_vec()) .send() .await .expect("post ok"); assert_eq!(response.status().as_u16(), 202); // Capture the request that wiremock received and re-verify the // signature against the published public PEM. This proves the // signed input string is what reqwest actually puts on the wire. let received: Vec<Request> = server.received_requests().await.unwrap(); assert_eq!(received.len(), 1); let req = &received[0]; let header_lookup = |name: &str| -> Option<String> { // wiremock lowercases header names already. req.headers .get(name) .map(|v| v.to_str().unwrap().to_string()) }; let sig = header_lookup("signature").expect("signature header captured"); parse_and_verify_cavage( &sig, &format!("post {inbox_path}"), &req.body, &material.public_pem, header_lookup, ) .expect("signature must round-trip through wiremock"); } /// Minimal `OutboundDelivery` that signs + posts via reqwest using a /// pre-supplied keypair. Mirrors `HttpSignedDelivery::deliver` without /// the DB lookup so we can exercise `drain_one` against wiremock with /// only a `MockDatabase` for the queue. struct InMemorySignedDelivery { private_pem: String, key_id: String, } #[async_trait] impl OutboundDelivery for InMemorySignedDelivery { async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError> { let url = reqwest::Url::parse(&item.target_inbox) .map_err(|e| AppError::Internal(format!("bad URL: {e}")))?; let host = url .host_str() .ok_or_else(|| AppError::Internal("no host".into()))? .to_string(); let host_with_port = match url.port() { Some(p) => format!("{host}:{p}"), None => host.clone(), }; let path = url.path().to_string(); let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); let signature_header = sign_post( &self.private_pem, &self.key_id, &host_with_port, &path, &date, ); let response = Client::new() .post(&item.target_inbox) .header("Date", &date) .header("Host", &host_with_port) .header("Signature", &signature_header) .header("Content-Type", "application/activity+json") .body(item.payload_json.clone()) .send() .await .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; if response.status().is_success() { Ok(()) } else { Err(AppError::Internal(format!( "remote inbox responded {}", response.status(), ))) } } } fn pending_row(target_inbox: &str, target_domain: &str) -> fediverse_outbound_queue::Model { fediverse_outbound_queue::Model { id: Uuid::new_v4(), activity_id: "https://a.tld/activities/1".to_string(), payload_json: r#"{"type":"Update"}"#.to_string(), target_inbox: target_inbox.to_string(), target_domain: target_domain.to_string(), status: "pending".to_string(), attempts: 0, max_attempts: 8, last_error: None, next_retry_at: None, created_at: Utc::now(), delivered_at: None, actor_user_id: Some(Uuid::new_v4()), } } #[tokio::test] async fn outbound_delivers_when_inbox_returns_202() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/inbox")) .respond_with(ResponseTemplate::new(202)) .expect(1) .mount(&server) .await; let target_inbox = format!("{}/inbox", server.uri()); let row = pending_row(&target_inbox, "wiremock.local"); // Two fixtures: the SELECT-candidate then the SELECT-after-update // (sea-orm mock returns subsequent fixtures for `.update()` reads). let mut delivered = row.clone(); delivered.status = OutboundStatus::Delivered.as_str().to_string(); delivered.attempts = 1; delivered.delivered_at = Some(Utc::now()); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row.clone()]]) .append_query_results([vec![delivered.clone()]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let material = ActorKeyService::generate().expect("RSA gen"); let delivery = InMemorySignedDelivery { private_pem: material.private_pem, key_id: "https://a.tld/users/alice#main-key".to_string(), }; let result = FediverseOutboundService::drain_one(&db, &delivery) .await .expect("drain ok") .expect("row drained"); assert_eq!(result.status, OutboundStatus::Delivered.as_str()); assert_eq!(result.attempts, 1); assert!(result.delivered_at.is_some()); } #[tokio::test] async fn outbound_defers_when_inbox_returns_503() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/inbox")) .respond_with(ResponseTemplate::new(503)) .expect(1) .mount(&server) .await; let target_inbox = format!("{}/inbox", server.uri()); let row = pending_row(&target_inbox, "wiremock.local"); let mut deferred = row.clone(); deferred.status = OutboundStatus::Pending.as_str().to_string(); deferred.attempts = 1; deferred.last_error = Some("remote inbox responded 503".to_string()); deferred.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5)); let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![row.clone()]]) .append_query_results([vec![deferred.clone()]]) .append_exec_results([MockExecResult { last_insert_id: 0, rows_affected: 1, }]) .into_connection(); let material = ActorKeyService::generate().expect("RSA gen"); let delivery = InMemorySignedDelivery { private_pem: material.private_pem, key_id: "https://a.tld/users/alice#main-key".to_string(), }; let result = FediverseOutboundService::drain_one(&db, &delivery) .await .expect("drain ok") .expect("row drained"); assert_eq!(result.status, OutboundStatus::Pending.as_str()); assert_eq!(result.attempts, 1); assert!(result.last_error.is_some()); assert!(result.next_retry_at.is_some()); } // `Arc` is unused in this file but documents the intent of sharing the // MockServer handle across spawned tasks should we extend the harness. #[allow(dead_code)] fn _share_server(server: MockServer) -> Arc<MockServer> { Arc::new(server) }
GitRust