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 _ = 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", creds("alice"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let cookie = common::extract_session_cookie(&resp).expect("login sets a cookie"); assert!(cookie.starts_with("mangalord_session=")); } #[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 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(); assert!(body["token_hash"].is_null(), "token_hash must not leak"); // 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); }