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(&register_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", &register_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 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::<u32>().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");
}
