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); } #[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); }