//! Router-coverage CSRF test.
//!
//! Enumerates every mutating route registered through `CsrfRouter` (via the
//! manifest harvested at registration time, `makenotwork::csrf::route_manifest`)
//! and asserts the whole-router CSRF invariant in one place, instead of relying
//! on per-route tests, or on an audit, to notice a route that drifted.
//!
//! Why this exists: CSRF has surfaced in nearly every audit run because the
//! protection is enforced opt-in across ~350 per-route posture declarations plus
//! a multi-branch middleware. Each run an adversarial reader finds a different
//! site that drifted (route registration, the auto-posture pre-auth branch, a
//! mis-declared skip). This test converts "did every route get protected?" from
//! a code-reading exercise into a CI assertion that fails the moment a route
//! drifts.
use crate::harness::TestHarness;
use makenotwork::csrf::{ManifestPosture, route_manifest};
/// Auto-posture paths where an *outer* layer (e.g. the access gate) rejects the
/// test user with a non-403 status before the per-route CSRF layer runs, so the
/// strict `== 403` assertion below does not apply. Each entry must name the
/// layer that rejects it. The request is still refused, just not by the CSRF
/// layer, so excluding it does not weaken the security claim. Keep this list
/// short and justified; an unexplained entry is a smell.
const REJECTED_BEFORE_CSRF_LAYER: &[&str] = &[
// (populated empirically, see test output if this list is wrong)
];
// CHRONIC A′ (the pre-auth CSRF gap) is CLOSED as of 2026-06-15: `validate_auto`
// no longer skips token validation for logged-out callers, and the
// posture-independent `origin_gate` (layered on the whole `CsrfRouter` tree in
// `finalize`) rejects positively cross-site mutations. There is no longer a
// tracked-gap allowlist; `forgot_password_rejects_preauth_tokenless_post` below
// pins the fix. If a new pre-auth gap is ever knowingly accepted, reintroduce a
// justified allowlist + a test that flips when it closes (see git history for
// the prior `KNOWN_PREAUTH_CSRF_GAPS` forcing-function pattern).
/// Core coverage assertion: every Auto-posture route rejects an authenticated
/// request that carries no CSRF token. This catches a route that lost its auto
/// validation layer (the #18-class regression) across the whole router at once.
#[tokio::test]
async fn every_auto_route_rejects_authenticated_tokenless_mutation() {
// Log in as ADMIN so requests clear the access gate AND the `/admin*` admin
// gate (Run 20), reaching the per-route CSRF layer on every auto route,
// including admin mutations. A non-admin would be rejected by the admin gate
// (404) before the CSRF layer on `/admin*` routes, masking their CSRF
// coverage here. The client tracks the session cookie; `request_with_headers`
// deliberately does NOT inject a CSRF token, so each request is
// authenticated-but-tokenless.
let (mut h, _admin_id) = TestHarness::with_admin().await;
h.login("admin", "password123").await;
let manifest = route_manifest();
let auto_routes: Vec<_> = manifest
.iter()
.filter(|e| e.posture == ManifestPosture::Auto)
.collect();
assert!(
auto_routes.len() > 50,
"manifest looks empty/broken: {} auto of {} total routes",
auto_routes.len(),
manifest.len()
);
// Each manifest path may register any mutating method (single-method
// helpers like `put_csrf`, or multi-method `with_csrf(get().post())`); the
// manifest keys by path, not method. So probe the four mutating methods and
// require that the one the route actually handles is rejected with 403. A
// method the route does not handle returns 405 (the auto layer does not wrap
// the method-not-allowed fallback), that is not a CSRF result, so we move
// on. The client IP is rotated every request so the per-IP rate limiter
// (an outer layer that would 429 before the CSRF layer) never trips.
let mut ip_counter: u32 = 0;
let mut next_ip = |h: &mut TestHarness| {
ip_counter += 1;
h.client.set_forwarded_ip(&format!(
"10.50.{}.{}",
(ip_counter / 256) % 256,
ip_counter % 256
));
};
let mut failures = Vec::new();
for entry in &auto_routes {
if REJECTED_BEFORE_CSRF_LAYER.contains(&entry.path.as_str()) {
continue;
}
// A described screen mounts only when `QUASI_SCREENS` names it, and the
// manifest is a process-global: a test elsewhere in this binary that
// switches one on leaves an entry behind for a path this harness does
// not serve, and every method then answers 405. Skipping them is not a
// hole. `a_described_write_rejects_an_authenticated_tokenless_mutation`
// below probes the same surface with the screen actually on, which is
// the only configuration where the question means anything.
if makenotwork::quasi::PATHS.contains(&entry.path.as_str()) {
continue;
}
// Authenticated session + no CSRF token + no form content-type:
// `validate_auto` must return 403 (Forbidden) before the handler runs.
// Path params in the manifest (e.g. `/api/items/{id}`) still match the
// route pattern, and validation rejects before any param is parsed.
let mut outcome: Option<(&str, u16)> = None;
for method in ["POST", "PUT", "PATCH", "DELETE"] {
next_ip(&mut h);
let resp = h
.client
.request_with_headers(method, &entry.path, None, &[])
.await;
let code = resp.status.as_u16();
if code == 403 {
outcome = Some((method, code));
break;
}
if code != 405 {
// The route handled this method but did NOT reject, the real
// (and only interesting) failure case.
outcome = Some((method, code));
break;
}
// 405: route doesn't handle this method; try the next one.
}
match outcome {
Some((_, 403)) => {}
Some((method, code)) => failures.push(format!("{} [{method}] -> {code}", entry.path)),
None => failures.push(format!(
"{} -> all methods 405 (no mutating method?)",
entry.path
)),
}
}
assert!(
failures.is_empty(),
"{} Auto route(s) did NOT reject a tokenless authenticated request (CSRF \
layer missing/bypassed). If a route is legitimately refused earlier by \
an outer layer, add it to REJECTED_BEFORE_CSRF_LAYER with a reason:\n{}",
failures.len(),
failures.join("\n")
);
}
/// Manifest sanity: it is populated, the posture mix is plausible, and every
/// opt-out (Manual/Skip) carries a documented justification at its call site.
#[tokio::test]
async fn csrf_manifest_is_populated_and_optouts_are_justified() {
let _h = TestHarness::new().await; // building the app populates the manifest
let manifest = route_manifest();
let count = |p: ManifestPosture| manifest.iter().filter(|e| e.posture == p).count();
let (auto, manual, skip) = (
count(ManifestPosture::Auto),
count(ManifestPosture::Manual),
count(ManifestPosture::Skip),
);
eprintln!(
"CSRF manifest: {auto} auto, {manual} manual, {skip} skip, {} total",
manifest.len()
);
assert!(
manifest.len() > 100,
"manifest too small: {}",
manifest.len()
);
assert!(auto > 50, "expected many Auto routes, got {auto}");
for e in &manifest {
if matches!(e.posture, ManifestPosture::Manual | ManifestPosture::Skip) {
assert!(
e.reason.is_some_and(|r| !r.trim().is_empty()),
"{:?} route {} has no documented CSRF justification",
e.posture,
e.path
);
}
}
}
/// CHRONIC A′ regression test (gap CLOSED 2026-06-15). `/forgot-password` is
/// Auto-posture and always reached logged-out. It used to slip through because
/// `validate_auto` skipped token validation for logged-out callers. That skip
/// is gone, so a pre-auth tokenless POST is now rejected by the per-route token
/// check. This pins the fix: if the `!has_user` skip is ever reintroduced, this
/// flips red.
#[tokio::test]
async fn forgot_password_rejects_preauth_tokenless_post() {
let mut h = TestHarness::new().await;
// Anonymous client (no signup) + no CSRF token + a real-looking form body.
// No Origin/Sec-Fetch-Site headers, so the origin_gate allows it through,
// the rejection here comes from the per-route token check (the seal for
// header-less forged clients the origin gate intentionally lets pass).
let resp = h
.client
.request_with_headers(
"POST",
"/forgot-password",
Some("email=nobody@example.com"),
&[("Content-Type", "application/x-www-form-urlencoded")],
)
.await;
assert_eq!(
resp.status, 403,
"CHRONIC A' regressed: /forgot-password accepted a pre-auth tokenless \
POST (got {}). The validate_auto !has_user skip must stay removed.",
resp.status
);
}
/// The posture-independent origin gate rejects a positively cross-site mutating
/// request regardless of posture or auth state, before the handler runs.
#[tokio::test]
async fn origin_gate_rejects_cross_site_mutation() {
let mut h = TestHarness::new().await;
let resp = h
.client
.request_with_headers(
"POST",
"/forgot-password",
Some("email=nobody@example.com"),
&[
("Content-Type", "application/x-www-form-urlencoded"),
("Sec-Fetch-Site", "cross-site"),
],
)
.await;
assert_eq!(
resp.status, 403,
"origin gate let a Sec-Fetch-Site: cross-site mutation through (got {})",
resp.status
);
}
/// A same-origin `Sec-Fetch-Site` signal must pass the origin gate. The probe is
/// a safe GET to `/forgot-password`: a 200 shows the gate did not block on the
/// same-origin signal.
#[tokio::test]
async fn origin_gate_allows_same_origin_signal() {
let mut h = TestHarness::new().await;
// same-origin Sec-Fetch-Site must pass the gate; with no token it then hits
// the token check. Use an authenticated session + a valid token would be a
// fuller test, but here we only assert the gate itself does not 403 on a
// same-origin signal by confirming the failure mode is the token layer, not
// an early gate block. A same-origin GET (safe method) is the cleanest probe.
let resp = h
.client
.request_with_headers(
"GET",
"/forgot-password",
None,
&[("Sec-Fetch-Site", "same-origin")],
)
.await;
assert_eq!(
resp.status, 200,
"origin gate or routing blocked a same-origin safe request (got {})",
resp.status
);
}
/// The description layer's mounts are inside the CSRF envelope, not beside it.
///
/// They sat outside until 2026-08-11: `build_app` nested them after
/// `with_state`, so neither the origin gate nor the token check reached them.
/// That was harmless only while described screens served nothing but GET, and it
/// stopped being harmless the moment one served its own `DELETE`. This asserts
/// the structural fact rather than the behaviour, because the behaviour is only
/// observable when a screen is switched on and `QUASI_SCREENS` is unset here and
/// in every deployment.
#[tokio::test]
async fn described_screens_register_inside_the_csrf_envelope() {
// Building the app is what populates the manifest.
let _h = TestHarness::new().await;
let manifest = route_manifest();
// With no screen switched on there is nothing to find, which is the state
// this test runs in. What it pins is that `mounts` is wired through
// `CsrfRouter::nest_service`, so any screen that switches on is covered:
// an entry appears if and only if a mount did.
for entry in &manifest {
if entry.path.starts_with("/library/tabs/") || entry.path.starts_with("/dashboard/tabs/") {
assert_eq!(
entry.posture,
ManifestPosture::Auto,
"a described mount declared a posture other than Auto: {} ({:?})",
entry.path,
entry.reason
);
}
}
// The seal that actually matters, and the one a future refactor would trip:
// `CsrfRouter::nest_service` is the only way a service-shaped sub-tree gets
// in, and it records Auto unconditionally. If someone reaches for axum's
// `nest_service` on the finalized router again, the mount silently leaves
// the envelope and nothing above notices, so the source is checked too.
let lib = include_str!("../../src/lib.rs");
let mounts = lib
.split_once("quasi::mounts(state)")
.expect("build_app mounts the description layer")
.1;
let (mounted, _) = mounts
.split_once(".finalize()")
.expect("the CSRF tree is finalized after the mounts");
assert!(
mounted.contains("routes.nest_service"),
"the described mounts must go through CsrfRouter::nest_service and land \
before finalize, not on the plain router afterwards: see the CSRF \
envelope note in build_app"
);
}
/// A described write refuses a tokenless mutation, like every other write.
///
/// The behavioural half of the test above. Switching a screen on is what makes
/// the described sub-tree reachable, and `library_contacts` is the one that
/// serves its own `DELETE`: it revokes contact sharing and answers with the tab
/// as it now stands, rather than calling the API route whose 204 htmx never
/// swapped.
#[tokio::test]
async fn a_described_write_rejects_an_authenticated_tokenless_mutation() {
let mut h = TestHarness::build(crate::harness::BuildOptions {
..Default::default()
})
.await;
h.signup("creator", "creator@example.com", "password123")
.await;
h.login("creator", "password123").await;
// A real seller id is not needed: the CSRF layer wraps the whole nest, so it
// answers before the router is consulted about whether the path exists.
// That order is deliberate; see `CsrfRouter::nest_service`.
let resp = h
.client
.request_with_headers(
"DELETE",
"/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000",
None,
&[],
)
.await;
assert_eq!(
resp.status, 403,
"a described write accepted a tokenless mutation (got {})",
resp.status
);
}
/// A described write SUCCEEDS with the token the document around it carries.
///
/// The other half, and the one that was missing. The test above asserts the
/// enforcement works; nothing asserted a described write can be completed at
/// all, so a surface where every write answered 403 would have read as covered.
///
/// # Where the token comes from, measured 2026-08-18
///
/// Every described screen answers a `Response::fragment`, so it is the inside of
/// an element in an Askama document, and that document is what carries the
/// token: `base.html` emits `` and the core module
/// attaches it to every htmx request on the page. So this walks the browser's
/// path exactly -- read the page, take the meta, use it on the write -- with the
/// test doing by hand what `htmx-glue.ts` does on `htmx:config:request`.
///
/// The document-owning case is the other half and no screen exercises it yet:
/// see `a_described_screen_never_builds_its_own_shell` below and
/// `Viewer::shell`.
#[tokio::test]
async fn a_described_write_accepts_the_token_the_page_around_it_carries() {
let mut h = TestHarness::build(crate::harness::BuildOptions {
..Default::default()
})
.await;
h.signup("creator", "creator@example.com", "password123")
.await;
h.login("creator", "password123").await;
// The document the tab lives in, not the tab: the tab is a fragment and a
// fragment carries no head.
let page = h.client.get("/library").await;
assert_eq!(page.status, 200, "the library page did not render");
let token = meta_csrf_token(&page.text)
.expect("the page around a described screen carries no csrf-token meta");
let resp = h
.client
.request_with_headers(
"DELETE",
"/library/tabs/contacts/revoke/00000000-0000-0000-0000-000000000000",
None,
&[("X-CSRF-Token", token.as_str()), ("HX-Request", "true")],
)
.await;
// What the write answers about a contact that does not exist is the
// handler's business. This asserts only that it reached the handler.
assert_ne!(
resp.status, 403,
"a described write refused the token its own document supplied"
);
}
/// The token out of ``, the way the glue reads it.
fn meta_csrf_token(html: &str) -> Option {
let at = html.split("name=\"csrf-token\"").nth(1)?;
let at = at.split("content=\"").nth(1)?;
Some(at.split('"').next()?.to_owned())
}
/// No described screen builds its own shell.
///
/// The failure this forbids is silent and one line long: a screen that writes
/// `Shell::under("/static")` by hand gets a document with no session token in
/// it, and every write on that screen answers 403 the moment the screen owns
/// its document rather than answering into an Askama page. Five screens wrote
/// that line before `Viewer::shell` existed, which was five chances to forget.
///
/// Source-level rather than behavioural because there is nothing to observe
/// yet: every described screen answers a fragment today, and a fragment has no
/// `` to carry anything. The test that would catch it is the one that
/// cannot be written until the mistake ships.
#[test]
fn a_described_screen_never_builds_its_own_shell() {
for (name, source) in [
("ssh_keys", include_str!("../../src/quasi/ssh_keys.rs")),
(
"library_contacts",
include_str!("../../src/quasi/library_contacts.rs"),
),
(
"buyer_contacts",
include_str!("../../src/quasi/buyer_contacts.rs"),
),
(
"user_analytics",
include_str!("../../src/quasi/user_analytics.rs"),
),
(
"forum_memberships",
include_str!("../../src/quasi/forum_memberships.rs"),
),
] {
assert!(
!source.contains("Shell::under"),
"{name} builds its own shell: use `viewer.shell()`, which carries \
the session token every write on a described document needs"
);
}
}