//! WP-01: /api/auth/* routes. mod common; use axum::http::StatusCode; use common::{cookie_value, get, post, post_with_cookie, test_app_with_admin}; use serde_json::json; const ADMIN: &str = "admin@example.com"; const PW: &str = "admin-password-123"; #[tokio::test] async fn login_returns_access_token_and_sets_httponly_refresh_cookie() { let app = test_app_with_admin().await; let res = post( &app, "/api/auth/login", json!({"email": ADMIN, "password": PW}), None, ) .await; assert_eq!(res.status, StatusCode::OK, "{}", res.json); assert!(res.json["access_token"].as_str().unwrap().len() > 20); assert_eq!(res.json["user"]["email"], ADMIN); assert_eq!(res.json["user"]["role"], "admin"); assert!(res.json["user"].get("password_hash").is_none()); let cookie = res.headers.get("set-cookie").unwrap().to_str().unwrap(); assert!(cookie.starts_with("refresh_token=")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); assert!(cookie.contains("Path=/api/auth")); } #[tokio::test] async fn login_with_wrong_password_is_401_without_details() { let app = test_app_with_admin().await; let res = post( &app, "/api/auth/login", json!({"email": ADMIN, "password": "nope-nope-nope"}), None, ) .await; assert_eq!(res.status, StatusCode::UNAUTHORIZED); assert_eq!(res.json["error"], "invalid_credentials"); let res = post( &app, "/api/auth/login", json!({"email": "x@y.z", "password": "nope-nope-nope"}), None, ) .await; assert_eq!(res.status, StatusCode::UNAUTHORIZED); assert_eq!(res.json["error"], "invalid_credentials"); } #[tokio::test] async fn login_of_inactive_user_is_403() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; let created = post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&token)).await; let id = created.json["id"].as_str().unwrap(); common::patch( &app, &format!("/api/users/{id}"), json!({"is_active": false}), Some(&token), ) .await; let res = post( &app, "/api/auth/login", json!({"email": "u@x.de", "password": "user-password-123"}), None, ) .await; assert_eq!(res.status, StatusCode::FORBIDDEN); assert_eq!(res.json["error"], "inactive_user"); } #[tokio::test] async fn login_is_rate_limited_per_ip() { let app = test_app_with_admin().await; let mut last = StatusCode::OK; for _ in 0..12 { last = post( &app, "/api/auth/login", json!({"email": ADMIN, "password": "wrong-wrong-wrong"}), None, ) .await .status; } assert_eq!(last, StatusCode::TOO_MANY_REQUESTS); } #[tokio::test] async fn refresh_rotates_cookie_and_returns_new_access_token() { let app = test_app_with_admin().await; let login = post( &app, "/api/auth/login", json!({"email": ADMIN, "password": PW}), None, ) .await; let cookie = cookie_value(&login, "refresh_token").unwrap(); let res = post_with_cookie(&app, "/api/auth/refresh", &cookie).await; assert_eq!(res.status, StatusCode::OK, "{}", res.json); assert!(res.json["access_token"].as_str().is_some()); let new_cookie = cookie_value(&res, "refresh_token").unwrap(); assert_ne!(new_cookie, cookie); // old cookie is rejected after rotation let res = post_with_cookie(&app, "/api/auth/refresh", &cookie).await; assert_eq!(res.status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn refresh_without_cookie_is_401() { let app = test_app_with_admin().await; let res = post(&app, "/api/auth/refresh", json!({}), None).await; assert_eq!(res.status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn logout_clears_cookie_and_revokes_refresh_token() { let app = test_app_with_admin().await; let login = post( &app, "/api/auth/login", json!({"email": ADMIN, "password": PW}), None, ) .await; let cookie = cookie_value(&login, "refresh_token").unwrap(); let res = post_with_cookie(&app, "/api/auth/logout", &cookie).await; assert_eq!(res.status, StatusCode::NO_CONTENT); let cleared = res.headers.get("set-cookie").unwrap().to_str().unwrap(); assert!(cleared.contains("Max-Age=0")); assert_eq!( post_with_cookie(&app, "/api/auth/refresh", &cookie) .await .status, StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn me_requires_bearer_token() { let app = test_app_with_admin().await; assert_eq!( get(&app, "/api/auth/me", None).await.status, StatusCode::UNAUTHORIZED ); assert_eq!( get(&app, "/api/auth/me", Some("garbage")).await.status, StatusCode::UNAUTHORIZED ); let token = common::login(&app, ADMIN, PW).await.access; let res = get(&app, "/api/auth/me", Some(&token)).await; assert_eq!(res.status, StatusCode::OK); assert_eq!(res.json["email"], ADMIN); } #[tokio::test] async fn bootstrap_admin_is_created_once_on_empty_database() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; let users = get(&app, "/api/users", Some(&token)).await; assert_eq!(users.json.as_array().unwrap().len(), 1); }