test: tighten audit-flagged tests and add missing coverage

Tightens three tests whose names overstated what they checked:

- `login_succeeds_and_rotates_session` now asserts the login cookie
  differs from the registration cookie, and that the registration
  cookie is still valid after login (the documented contract).
- `storage::local::rejects_path_traversal` exercises three extra
  rejection paths the existing implementation already handled but the
  tests didn't probe: `a/./b`, the single-segment `.`, and the empty
  segment `a//b`.
- `create_and_use_bot_token` asserts that `token_hash` is *absent*
  from the response (`get(...).is_none()`), not just `is_null()`,
  which would have accepted an explicit `"token_hash": null` payload
  too.

Adds four coverage cases that the audit flagged as missing:

- `me_rejects_expired_session` — hand-craft a session row with
  `expires_at = now() - 1h`, hit `/auth/me` with the matching cookie,
  expect 401 + `unauthenticated`. Proves the extractor's
  `expires_at > now()` filter is wired.
- `concurrent_manga_bookmarks_serialised_by_unique_index` — spawn two
  POSTs in parallel for the same `(user, manga, chapter=null)`,
  assert one wins (201) and one collides (409) via the partial unique
  index from migration 0004.
- `bookmark_create_accepts_bearer_token` — mint a bot token and POST
  /bookmarks with `Authorization: Bearer`, asserting `CurrentUser`
  resolves identically to the cookie path on a write endpoint (not
  just `/auth/me`).
- Three new unit tests on `app::cors_layer` covering the allowlist
  (origin reflected, credentials true), a foreign origin (no
  allow-origin header emitted), and the same-origin default (empty
  allowlist emits no CORS headers at all).

`cors_layer` is `pub(crate)` now so the tests in `app::tests` can
reach it; the function itself is unchanged.

No version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 23:30:19 +02:00
parent 785b9755cf
commit 2df4084c56
5 changed files with 243 additions and 8 deletions

View File

@@ -48,7 +48,7 @@ pub fn router(state: AppState) -> Router {
.layer(TraceLayer::new_for_http())
}
fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
pub(crate) fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
if allowed_origins.is_empty() {
// Same-origin only — no CORS headers emitted.
return CorsLayer::new();
@@ -66,3 +66,80 @@ fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
HeaderName::from_static("authorization"),
])
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use axum::routing::get;
use tower::ServiceExt;
fn test_router() -> Router {
Router::new().route("/", get(|| async { "ok" }))
}
#[tokio::test]
async fn allowlist_preflight_emits_credentialed_headers() {
let app = test_router().layer(cors_layer(&["https://app.example.com".to_string()]));
let resp = app
.oneshot(
Request::builder()
.method(Method::OPTIONS)
.uri("/")
.header("origin", "https://app.example.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.headers().get("access-control-allow-origin").unwrap(),
"https://app.example.com"
);
assert_eq!(
resp.headers().get("access-control-allow-credentials").unwrap(),
"true"
);
}
#[tokio::test]
async fn allowlist_rejects_unlisted_origin() {
let app = test_router().layer(cors_layer(&["https://app.example.com".to_string()]));
let resp = app
.oneshot(
Request::builder()
.method(Method::OPTIONS)
.uri("/")
.header("origin", "https://evil.example.org")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Browsers will refuse the response when the allow-origin header
// is absent (or doesn't echo the requesting origin).
assert!(resp.headers().get("access-control-allow-origin").is_none());
}
#[tokio::test]
async fn empty_allowlist_is_same_origin_only() {
let app = test_router().layer(cors_layer(&[]));
let resp = app
.oneshot(
Request::builder()
.method(Method::OPTIONS)
.uri("/")
.header("origin", "https://app.example.com")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(resp.headers().get("access-control-allow-origin").is_none());
assert!(resp.headers().get("access-control-allow-credentials").is_none());
}
}