Files
Mangalord/backend/src/app.rs
MechaCat02 2df4084c56 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>
2026-05-16 23:30:19 +02:00

146 lines
4.7 KiB
Rust

use std::sync::Arc;
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderName, HeaderValue, Method};
use axum::Router;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use crate::config::{AuthConfig, Config, UploadConfig};
use crate::storage::{LocalStorage, Storage};
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub storage: Arc<dyn Storage>,
pub auth: AuthConfig,
pub upload: UploadConfig,
}
pub async fn build(config: Config) -> anyhow::Result<Router> {
let db = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
sqlx::migrate!("./migrations").run(&db).await?;
let storage: Arc<dyn Storage> = Arc::new(LocalStorage::new(config.storage_dir.clone()));
let state = AppState {
db,
storage,
auth: config.auth.clone(),
upload: config.upload.clone(),
};
Ok(router(state).layer(cors_layer(&config.cors_allowed_origins)))
}
/// Build a router from a pre-assembled state. Used by integration tests
/// so they can swap in a test DB pool and a `tempfile`-backed storage.
pub fn router(state: AppState) -> Router {
let max_request_bytes = state.upload.max_request_bytes;
Router::new()
.nest("/api/v1", crate::api::routes())
.layer(DefaultBodyLimit::max(max_request_bytes))
.with_state(state)
.layer(TraceLayer::new_for_http())
}
pub(crate) fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
if allowed_origins.is_empty() {
// Same-origin only — no CORS headers emitted.
return CorsLayer::new();
}
let origins: Vec<HeaderValue> = allowed_origins
.iter()
.filter_map(|o| HeaderValue::from_str(o).ok())
.collect();
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([
HeaderName::from_static("content-type"),
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());
}
}