From 2f610aecb2979d66115b8f95961378520afea94e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 8 Jul 2026 09:23:17 -0400 Subject: [PATCH] test(harness): expose response headers + config override; verify per-route CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration harness dropped response headers (returned only status + JSON body), which is exactly why CORS/security-header behavior couldn't be tested. Add: - request_full(method, uri, extra_headers, body) -> (status, HeaderMap, body): sends arbitrary request headers (e.g. an Origin) and returns response headers. - try_app_with(|cfg| ...): tweak the validated Config before building the router (e.g. restrict cors_allowed_origins). try_app now delegates to it. New tests/cors.rs proves the well-known fix end-to-end: with the API CORS restricted to one origin, /.well-known/nostr.json still serves ACAO: * to a foreign origin (federation works) while /api/v1/* does NOT — the two policies are genuinely distinct. Verified against Postgres (passes); existing auth_flow/capabilities unaffected. --- tests/common/mod.rs | 48 +++++++++++++++++++++++++++++++++- tests/cors.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/cors.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 9536912..8b5766b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -49,9 +49,16 @@ fn init_test_env(database_url: &str) { /// The router + state under test, or `None` if no `TEST_DATABASE_URL` is set. pub async fn try_app() -> Option { + try_app_with(|_| {}).await +} + +/// Like [`try_app`], but lets a test tweak the validated `Config` before the router +/// is built — e.g. restrict `cors_allowed_origins` to assert per-route CORS policy. +pub async fn try_app_with(mutate: impl FnOnce(&mut Config)) -> Option { let url = std::env::var("TEST_DATABASE_URL").ok()?; init_test_env(&url); - let config = Config::from_env().expect("valid test config"); + let mut config = Config::from_env().expect("valid test config"); + mutate(&mut config); let pool = PgPoolOptions::new() .max_connections(5) @@ -178,6 +185,45 @@ impl TestApp { (status, json) } + /// Issue a request with arbitrary extra request headers (e.g. an `Origin` for + /// CORS assertions) and return the response STATUS + HEADERS + body. The plain + /// `request` helper drops headers, which hid CORS/security-header regressions. + pub async fn request_full( + &self, + method: &str, + uri: &str, + extra_headers: &[(&str, &str)], + body: Option, + ) -> (StatusCode, axum::http::HeaderMap, Value) { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .extension(axum::extract::ConnectInfo(std::net::SocketAddr::from(( + [127, 0, 0, 1], + 0, + )))); + for (k, v) in extra_headers { + builder = builder.header(*k, *v); + } + let req = match body { + Some(b) => builder + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&b).unwrap())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + }; + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let json = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + }; + (status, headers, json) + } + /// Create a fresh user with a random identity. Returns the user id. pub async fn create_user(&self) -> Uuid { use smirk_backend_core::models::db::NewUser; diff --git a/tests/cors.rs b/tests/cors.rs new file mode 100644 index 0000000..ca1c967 --- /dev/null +++ b/tests/cors.rs @@ -0,0 +1,64 @@ +//! CORS policy per route group. The NIP-05 well-known directory is public data that +//! MUST be cross-origin fetchable for federation; the authenticated API honors the +//! operator's `CORS_ALLOWED_ORIGINS`. Regression guard for the per-route CORS split +//! in `build_router` (a single global layer used to let a restrictive API policy +//! silently break NIP-05 resolution). + +mod common; + +use axum::http::StatusCode; + +fn acao(headers: &axum::http::HeaderMap) -> Option { + headers + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +#[tokio::test] +async fn nip05_well_known_is_cross_origin_even_when_api_cors_restricted() { + // Operator restricts the API to their own frontend only. + let app = match common::try_app_with(|cfg| { + cfg.cors_allowed_origins = vec!["https://app.smirk.cash".to_string()]; + }) + .await + { + Some(a) => a, + None => { + eprintln!("skipping: TEST_DATABASE_URL not set"); + return; + } + }; + + // The well-known, fetched from a FOREIGN origin (a Goblin domain), must be `*`. + let (status, headers, _body) = app + .request_full( + "GET", + "/.well-known/nostr.json?name=whoever", + &[("origin", "https://goblin.st")], + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + acao(&headers).as_deref(), + Some("*"), + "NIP-05 directory must serve ACAO: * so any domain can resolve names (federation)" + ); + + // The restricted API, from that same foreign origin, must NOT get `*` — proving + // the two CORS policies are genuinely distinct (not both permissive). + let (_s, api_headers, _b) = app + .request_full( + "GET", + "/api/v1/capabilities", + &[("origin", "https://goblin.st")], + None, + ) + .await; + assert_ne!( + acao(&api_headers).as_deref(), + Some("*"), + "the API must honor the operator's restricted CORS, not fall back to *" + ); +}