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>
146 lines
4.7 KiB
Rust
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());
|
|
}
|
|
}
|