mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
test(harness): expose response headers + config override; verify per-route CORS
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.
This commit is contained in:
+47
-1
@@ -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.
|
/// The router + state under test, or `None` if no `TEST_DATABASE_URL` is set.
|
||||||
pub async fn try_app() -> Option<TestApp> {
|
pub async fn try_app() -> Option<TestApp> {
|
||||||
|
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<TestApp> {
|
||||||
let url = std::env::var("TEST_DATABASE_URL").ok()?;
|
let url = std::env::var("TEST_DATABASE_URL").ok()?;
|
||||||
init_test_env(&url);
|
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()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
@@ -178,6 +185,45 @@ impl TestApp {
|
|||||||
(status, json)
|
(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<Value>,
|
||||||
|
) -> (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.
|
/// Create a fresh user with a random identity. Returns the user id.
|
||||||
pub async fn create_user(&self) -> Uuid {
|
pub async fn create_user(&self) -> Uuid {
|
||||||
use smirk_backend_core::models::db::NewUser;
|
use smirk_backend_core::models::db::NewUser;
|
||||||
|
|||||||
@@ -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<String> {
|
||||||
|
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 *"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user