mod common; use axum::http::{header, StatusCode}; use serde_json::json; use sqlx::PgPool; use tower::ServiceExt; fn creds(username: &str) -> serde_json::Value { json!({ "username": username, "password": "hunter2hunter2" }) } #[sqlx::test(migrations = "./migrations")] async fn register_creates_user_and_sets_session_cookie(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::post_json( "/api/v1/auth/register", creds("alice"), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let cookie_header = resp .headers() .get(header::SET_COOKIE) .expect("Set-Cookie present") .to_str() .unwrap() .to_string(); assert!(cookie_header.starts_with("mangalord_session=")); assert!(cookie_header.contains("HttpOnly")); assert!(cookie_header.contains("SameSite=Lax")); assert!(cookie_header.contains("Path=/")); // In the test harness cookie_secure is false; production has Secure. assert!(!cookie_header.contains("Secure")); let body = common::body_json(resp).await; assert_eq!(body["user"]["username"], "alice"); assert!(body["user"]["id"].as_str().is_some()); assert!( body["user"].get("password_hash").is_none(), "password_hash must never leak to the API" ); } #[sqlx::test(migrations = "./migrations")] async fn register_rejects_duplicate_username_with_conflict(pool: PgPool) { let h = common::harness(pool); let _ = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) .await .unwrap(); let resp = h .app .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "conflict"); } #[sqlx::test(migrations = "./migrations")] async fn register_rejects_case_only_username_collisions(pool: PgPool) { let h = common::harness(pool); let _ = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) .await .unwrap(); // Mixed-case variant collides via the lower(username) index. let resp = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("Alice"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); // Login with either casing finds the same user. let resp = h .app .oneshot(common::post_json("/api/v1/auth/login", creds("ALICE"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn register_rejects_short_password(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::post_json( "/api/v1/auth/register", json!({ "username": "alice", "password": "short" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "invalid_input"); } #[sqlx::test(migrations = "./migrations")] async fn login_succeeds_and_rotates_session(pool: PgPool) { let h = common::harness(pool); let register_resp = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) .await .unwrap(); let register_cookie = common::extract_session_cookie(®ister_resp) .expect("register sets a cookie"); let login_resp = h .app .clone() .oneshot(common::post_json("/api/v1/auth/login", creds("alice"))) .await .unwrap(); assert_eq!(login_resp.status(), StatusCode::OK); let login_cookie = common::extract_session_cookie(&login_resp).expect("login sets a cookie"); assert!(login_cookie.starts_with("mangalord_session=")); // Login must mint a *new* session, not echo the registration one. assert_ne!( register_cookie, login_cookie, "login should rotate the session token; got the register cookie back" ); // The registration cookie is still valid until it expires naturally — // that's the documented behaviour, asserted here so a regression that // invalidates other devices' sessions on login would be noisy. let me_resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me", ®ister_cookie)) .await .unwrap(); assert_eq!(me_resp.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn login_rejects_wrong_password(pool: PgPool) { let h = common::harness(pool); let _ = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) .await .unwrap(); let resp = h .app .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": "alice", "password": "wrongpassword" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "unauthenticated"); } #[sqlx::test(migrations = "./migrations")] async fn login_rejects_unknown_user(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::post_json("/api/v1/auth/login", creds("ghost"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn me_returns_user_with_valid_cookie(pool: PgPool) { let h = common::harness(pool); let (username, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["user"]["username"], username); } #[sqlx::test(migrations = "./migrations")] async fn me_returns_401_without_cookie(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::get("/api/v1/auth/me")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn logout_clears_session(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/logout", json!({}), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Same cookie no longer works. let resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn change_password_rotates_sessions_and_swaps_credentials(pool: PgPool) { let h = common::harness(pool); let (username, cookie) = common::register_user(&h.app).await; // Log in a second time to seed a "second device" session that // should also be invalidated by the password change. let second_resp = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": username, "password": "hunter2hunter2" }), )) .await .unwrap(); let second_cookie = common::extract_session_cookie(&second_resp).unwrap(); assert_ne!(cookie, second_cookie); // Change the password. let resp = h .app .clone() .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/password", json!({ "current_password": "hunter2hunter2", "new_password": "freshpassfreshpass" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let rotated_cookie = common::extract_session_cookie(&resp).expect("password change must mint a new cookie"); assert_ne!(cookie, rotated_cookie, "session must rotate"); // Both original cookies are dead (other devices signed out). for stale in [&cookie, &second_cookie] { let resp = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/auth/me", stale)) .await .unwrap(); assert_eq!( resp.status(), StatusCode::UNAUTHORIZED, "stale cookie {stale} should be invalid" ); } // The rotated cookie is live. let resp = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/auth/me", &rotated_cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); // Old password no longer logs in. let resp = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": username, "password": "hunter2hunter2" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // New password does. let resp = h .app .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": username, "password": "freshpassfreshpass" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn change_password_via_bearer_leaves_bearer_working(pool: PgPool) { // Bot scripts that call PATCH /me/password using Authorization: // Bearer must keep their bearer working — change_password only // wipes session rows, not api_tokens. Pin this behaviour so a // future refactor that wipes everything would fail noisily. let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "ci-bot" }), &cookie, )) .await .unwrap(); let bearer = common::body_json(resp).await["bearer"] .as_str() .unwrap() .to_string(); // Use the bearer to change the password. let resp = h .app .clone() .oneshot({ let body = json!({ "current_password": "hunter2hunter2", "new_password": "freshpassfreshpass" }); axum::http::Request::builder() .method("PATCH") .uri("/api/v1/auth/me/password") .header(axum::http::header::CONTENT_TYPE, "application/json") .header(axum::http::header::AUTHORIZATION, format!("Bearer {bearer}")) .body(axum::body::Body::from(body.to_string())) .unwrap() }) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Cookie is dead (all sessions wiped). let resp = h .app .clone() .oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // Bearer still works — that's the documented contract. let resp = h .app .oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn change_password_rejects_wrong_current_with_401(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/password", json!({ "current_password": "definitelyNotIt", "new_password": "freshpassfreshpass" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "unauthenticated"); } #[sqlx::test(migrations = "./migrations")] async fn change_password_rejects_weak_new_password(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::patch_json_with_cookie( "/api/v1/auth/me/password", json!({ "current_password": "hunter2hunter2", "new_password": "short" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "invalid_input"); } #[sqlx::test(migrations = "./migrations")] async fn change_password_requires_authentication(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::patch_json( "/api/v1/auth/me/password", json!({ "current_password": "hunter2hunter2", "new_password": "freshpassfreshpass" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[sqlx::test(migrations = "./migrations")] async fn me_rejects_expired_session(pool: PgPool) { use chrono::{Duration, Utc}; use mangalord::auth::token::generate_token; let h = common::harness(pool.clone()); common::register_user(&h.app).await; // Grab the user that was just registered so we can hand-craft an // expired session for them. let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users LIMIT 1") .fetch_one(&pool) .await .unwrap(); let (raw, hash) = generate_token(); let expires_at = Utc::now() - Duration::hours(1); sqlx::query( "INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", ) .bind(user_id) .bind(&hash[..]) .bind(expires_at) .execute(&pool) .await .unwrap(); let cookie = format!("mangalord_session={raw}"); let resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/me", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "unauthenticated"); } #[sqlx::test(migrations = "./migrations")] async fn create_and_use_bot_token(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "ci-bot" }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; assert_eq!(body["name"], "ci-bot"); let bearer = body["bearer"] .as_str() .expect("raw bearer in response") .to_string(); // `token_hash` is `#[serde(skip)]` on `ApiToken`, so it must be // *absent* from the JSON. `is_null()` would also accept a // `"token_hash": null` payload, which we don't want — use // `get(...).is_none()` for the stronger assertion. assert!( body.get("token_hash").is_none(), "token_hash must not appear in the response at all" ); // Use the bearer to hit /me — should authenticate. let resp = h .app .oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[sqlx::test(migrations = "./migrations")] async fn user_a_cannot_delete_user_b_token(pool: PgPool) { let h = common::harness(pool); let (_, cookie_a) = common::register_user(&h.app).await; let (_, cookie_b) = common::register_user(&h.app).await; let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "alice-bot" }), &cookie_a, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; let token_id = body["id"].as_str().unwrap().to_string(); // User B attempts to delete user A's token → 403. let resp = h .app .clone() .oneshot(common::delete_with_cookie( &format!("/api/v1/auth/tokens/{token_id}"), &cookie_b, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "forbidden"); // User A succeeds. let resp = h .app .oneshot(common::delete_with_cookie( &format!("/api/v1/auth/tokens/{token_id}"), &cookie_a, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } /// Username enumeration via login response time: an attacker probes /// for valid usernames by measuring how long /auth/login takes. Before /// the equalisation fix, the no-user branch returned 401 in <1 ms /// while the wrong-password branch took ~50-100 ms (the argon2 verify /// cost). This test asserts the no-user branch now spends at least /// some meaningful fraction of the wrong-password branch's time. /// /// Tolerance is intentionally loose so CI variance doesn't flap the /// test. The unequalised gap is large enough (~50x) that even a noisy /// CI run with a 5x slack still catches it. #[sqlx::test(migrations = "./migrations")] async fn login_no_user_branch_runs_argon2_for_timing_equalisation(pool: PgPool) { use std::time::Instant; let h = common::harness(pool); // Register the victim user so the wrong-password branch has a real // argon2 hash to verify against. let _ = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/register", json!({ "username": "victim", "password": "hunter2hunter2" }), )) .await .unwrap(); // Warm-up: first login of the process initialises the dummy hash // lazily. Skip that cost when measuring. let _ = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": "victim", "password": "wrong" }), )) .await .unwrap(); let _ = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": "ghost", "password": "wrong" }), )) .await .unwrap(); // Median-of-N is more stable than a single sample. async fn sample_min( app: &axum::Router, username: &str, n: u32, ) -> std::time::Duration { let mut samples = Vec::with_capacity(n as usize); for _ in 0..n { let req = common::post_json( "/api/v1/auth/login", json!({ "username": username, "password": "wrong-guess" }), ); let t = Instant::now(); let resp = app.clone().oneshot(req).await.unwrap(); let d = t.elapsed(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); samples.push(d); } // Use the minimum: it's the floor that argon2 takes, robust // against unrelated stalls (DB connection acquisition, etc.). *samples.iter().min().unwrap() } let wrong_pwd = sample_min(&h.app, "victim", 3).await; let no_user = sample_min(&h.app, "ghost", 3).await; // 5x slack: argon2 dominates both branches, so they should be // within an order of magnitude. Unequalised, no_user would be // ~50-100x faster. Asserting "no_user >= wrong_pwd / 5" catches // the bug without being flaky in CI. assert!( no_user * 5 >= wrong_pwd, "login timing leaks user existence: no_user={no_user:?}, wrong_pwd={wrong_pwd:?}" ); } /// Brute-force / spray protection: at default production limits, a /// tight loop of /auth/login attempts should burst through the bucket /// and then 429 every subsequent request until the bucket refills. #[sqlx::test(migrations = "./migrations")] async fn login_rate_limited_under_burst_pressure(pool: PgPool) { let h = common::harness_with_auth_rate_limit(pool, 1, 3); // Register a victim so the wrong-password branch is real work. let _ = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("victim"))) .await .unwrap(); // Register consumed one token from the burst-3 bucket. Fire 30 // wrong-password logins back-to-back; with per_sec=1 the refill // is too slow to keep up and at least one must come back 429. let mut saw_429 = false; for _ in 0..30 { let resp = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": "victim", "password": "wrong" }), )) .await .unwrap(); if resp.status() == StatusCode::TOO_MANY_REQUESTS { // RFC 6585 §4: 429 SHOULD include a Retry-After header. The // value is in seconds; with per_sec=1 the bucket needs ~1s // to refill, so the header should be 1 or 2. let retry_after = resp .headers() .get(axum::http::header::RETRY_AFTER) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .expect("Retry-After header present and numeric"); assert!( retry_after >= 1, "Retry-After must be at least 1s, got {retry_after}" ); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "too_many_requests"); saw_429 = true; break; } } assert!( saw_429, "expected at least one 429 within 30 rapid login attempts" ); } /// Default (test-harness) limits are disabled, so existing tests that /// fire multiple auth requests don't start failing. #[sqlx::test(migrations = "./migrations")] async fn default_test_harness_does_not_rate_limit(pool: PgPool) { let h = common::harness(pool); for i in 0..50 { let resp = h .app .clone() .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": format!("nobody-{i}"), "password": "x" }), )) .await .unwrap(); // None of these should be 429 — only 401. assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "iter {i}"); } } #[sqlx::test(migrations = "./migrations")] async fn delete_unknown_token_is_404(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::delete_with_cookie( "/api/v1/auth/tokens/00000000-0000-0000-0000-000000000000", &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } /// Bot token names are user-supplied free-form strings; a 10 MB name /// was accepted before. Cap at 64 chars to match the other free-form /// identifier caps (tags, collection names). The response uses /// `ValidationFailed` (422 with per-field details) so clients can /// render the same shape they already handle for `attach_tag`. #[sqlx::test(migrations = "./migrations")] async fn create_token_rejects_name_over_64_chars(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; let resp = h .app .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "x".repeat(65) }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "validation_failed"); assert!(body["error"]["details"]["name"].is_string()); }