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" }) } /// A login request carrying a chosen `X-Forwarded-For`. Empty password so the /// handler consumes a rate-limit token then short-circuits with 400 before any /// argon2/DB work — the burst drains near-instantly regardless of CI load. fn login_from(xff: &str) -> axum::http::Request { axum::http::Request::builder() .method("POST") .uri("/api/v1/auth/login") .header(header::CONTENT_TYPE, "application/json") .header("x-forwarded-for", xff) .body(axum::body::Body::from( json!({ "username": "victim", "password": "" }).to_string(), )) .unwrap() } // The per-IP rate limiter's soundness hinges on one gate: X-Forwarded-For is // honored ONLY when AUTH_TRUSTED_PROXY is set. These two tests pin both sides of // that gate at the request level (the pure parser is unit-tested separately). #[sqlx::test(migrations = "./migrations")] async fn trusted_proxy_gives_each_forwarded_ip_its_own_bucket(pool: PgPool) { let h = common::harness_with_auth_rate_limit_proxy(pool, 1, 2, true); // Drain IP A's bucket (per_sec=1, burst=2) until it 429s. let mut a_saw_429 = false; for _ in 0..8 { let resp = h.app.clone().oneshot(login_from("203.0.113.10")).await.unwrap(); if resp.status() == StatusCode::TOO_MANY_REQUESTS { a_saw_429 = true; break; } } assert!(a_saw_429, "IP A must be rate-limited after draining its own bucket"); // A different forwarded IP has an independent bucket — its first hit is not 429. let resp_b = h.app.clone().oneshot(login_from("198.51.100.20")).await.unwrap(); assert_ne!( resp_b.status(), StatusCode::TOO_MANY_REQUESTS, "a distinct X-Forwarded-For hop must get its own bucket, not A's drained one" ); } #[sqlx::test(migrations = "./migrations")] async fn untrusted_proxy_ignores_forwarded_ip_and_shares_one_bucket(pool: PgPool) { let h = common::harness_with_auth_rate_limit_proxy(pool, 1, 2, false); // Every request carries a DISTINCT (spoofed) X-Forwarded-For, but the backend // doesn't trust it — so they all fall back to the single global bucket, which // drains and starts returning 429. If XFF were wrongly honored here, each // unique IP would get a fresh bucket and none of these would ever 429. let mut saw_429 = false; for i in 0..12 { let resp = h .app .clone() .oneshot(login_from(&format!("10.0.0.{i}"))) .await .unwrap(); if resp.status() == StatusCode::TOO_MANY_REQUESTS { saw_429 = true; break; } } assert!( saw_429, "with trusted_proxy off, spoofed X-Forwarded-For must NOT dodge the shared limiter" ); } #[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_oversized_password_before_argon2(pool: PgPool) { // A multi-KB password on the login path must be rejected as malformed // input (400) rather than fed to argon2 — otherwise every attempt is a // CPU-DoS. The account need not even exist; the guard is input-shape only. let h = common::harness(pool); let giant = "a".repeat(5000); let resp = h .app .oneshot(common::post_json( "/api/v1/auth/login", json!({ "username": "alice", "password": giant }), )) .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_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 list_tokens_returns_callers_tokens_scoped_and_without_hash(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; // Mint two tokens for this user, one with an expiry. for body in [ json!({ "name": "no-expiry" }), json!({ "name": "expiring", "expires_in_days": 30 }), ] { let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", body, &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); } // A second user's token must NOT appear in the first user's list. let (_, other) = common::register_user(&h.app).await; let _ = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "someone-elses" }), &other, )) .await .unwrap(); let resp = h .app .oneshot(common::get_with_cookie("/api/v1/auth/tokens", &cookie)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; let items = body["items"].as_array().unwrap(); assert_eq!(items.len(), 2, "only the caller's two tokens"); let names: Vec<&str> = items.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(names.contains(&"no-expiry") && names.contains(&"expiring")); // Raw secret / hash must never appear, but expiry metadata must. for t in items { assert!(t.get("token_hash").is_none(), "token_hash must be absent"); assert!(t.get("bearer").is_none(), "raw bearer only shown at creation"); assert!(t.get("expires_at").is_some(), "expiry metadata present"); } } #[sqlx::test(migrations = "./migrations")] async fn bot_token_with_future_expiry_authenticates(pool: PgPool) { // A token minted with expires_in_days is still active before its // expiry, and the response echoes a non-null expires_at. 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", "expires_in_days": 30 }), &cookie, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let body = common::body_json(resp).await; assert!( body["expires_at"].is_string(), "expires_at should be set, got {}", body["expires_at"] ); let bearer = body["bearer"].as_str().unwrap().to_string(); 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 expired_bot_token_is_rejected(pool: PgPool) { use chrono::{Duration, Utc}; use mangalord::auth::token::generate_token; let h = common::harness(pool.clone()); common::register_user(&h.app).await; let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users LIMIT 1") .fetch_one(&pool) .await .unwrap(); // Hand-craft a token that expired an hour ago. let (raw, hash) = generate_token(); let expires_at = Utc::now() - Duration::hours(1); sqlx::query( "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) \ VALUES ($1, 'stale', $2, $3)", ) .bind(user_id) .bind(&hash[..]) .bind(expires_at) .execute(&pool) .await .unwrap(); let resp = h .app .oneshot(common::get_with_bearer("/api/v1/auth/me", &raw)) .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_token_rejects_out_of_range_expiry(pool: PgPool) { let h = common::harness(pool); let (_, cookie) = common::register_user(&h.app).await; for days in [0, -5, 100_000] { let resp = h .app .clone() .oneshot(common::post_json_with_cookie( "/api/v1/auth/tokens", json!({ "name": "bad", "expires_in_days": days }), &cookie, )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::UNPROCESSABLE_ENTITY, "expires_in_days={days} should be rejected" ); } } #[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); // One register hit first — register and login share the one bucket, // so this exercises the cross-endpoint limit (and consumes a token). let _ = h .app .clone() .oneshot(common::post_json("/api/v1/auth/register", creds("victim"))) .await .unwrap(); // Fire 30 logins back-to-back; at least one must come back 429. // // We send an EMPTY password on purpose. `login` calls the rate // limiter FIRST, then short-circuits empty credentials with a 400 // BEFORE any argon2 hash or DB lookup — so each attempt still // consumes a token but is near-instant. That makes the burst drain // the bucket far faster than the per_sec=1 refill regardless of how // loaded the CI box is. (A real wrong-password attempt runs argon2, // ~1s under load, which lets the 1/sec refill keep exact pace with // the loop and the bucket never empties — that was the old flake. // The bucket math itself is covered by rate_limit.rs's unit test; // here we only need to prove the limiter is wired into the route.) 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": "" }), )) .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()); } // ---- self-register toggle + /auth/config ----------------------------------- #[sqlx::test(migrations = "./migrations")] async fn auth_config_reports_self_register_enabled_by_default(pool: PgPool) { let h = common::harness(pool); let resp = h .app .oneshot(common::get("/api/v1/auth/config")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["self_register_enabled"], true); } #[sqlx::test(migrations = "./migrations")] async fn auth_config_reflects_self_register_disabled(pool: PgPool) { let h = common::harness_with_self_register_disabled(pool); let resp = h .app .oneshot(common::get("/api/v1/auth/config")) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = common::body_json(resp).await; assert_eq!(body["self_register_enabled"], false); } #[sqlx::test(migrations = "./migrations")] async fn register_returns_403_when_self_register_disabled(pool: PgPool) { let h = common::harness_with_self_register_disabled(pool); let resp = h .app .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = common::body_json(resp).await; assert_eq!(body["error"]["code"], "forbidden"); }