WP-01: domain contract and failing tests for auth and user management
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Domain entities/ports, service stubs, 18 application unit tests with in-memory
fakes, API integration tests for /api/auth and /api/users, Vitest specs for the
auth store, login page and user form, Playwright auth/user-management flow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 21:31:59 +02:00
parent 780c84098b
commit 83aa500f5d
21 changed files with 1427 additions and 29 deletions

View File

@ -0,0 +1,175 @@
//! 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);
}