WP-01: domain contract and failing tests for auth and user management
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:
175
backend/crates/api/tests/auth.rs
Normal file
175
backend/crates/api/tests/auth.rs
Normal 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);
|
||||
}
|
||||
@ -87,3 +87,28 @@ pub fn cookie_value(res: &Res, name: &str) -> Option<String> {
|
||||
.find(|c| c.starts_with(&format!("{name}=")))
|
||||
.map(|c| c.split(';').next().unwrap().to_string())
|
||||
}
|
||||
|
||||
/// App with a bootstrapped admin `admin@example.com` / `admin-password-123`.
|
||||
pub async fn test_app_with_admin() -> Router {
|
||||
api::test_support::build_test_app_with_admin("admin@example.com", "admin-password-123").await
|
||||
}
|
||||
|
||||
pub struct Login {
|
||||
pub access: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
pub async fn login(app: &Router, email: &str, password: &str) -> Login {
|
||||
let res = post(
|
||||
app,
|
||||
"/api/auth/login",
|
||||
serde_json::json!({"email": email, "password": password}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::OK, "login failed: {}", res.json);
|
||||
Login {
|
||||
access: res.json["access_token"].as_str().unwrap().to_string(),
|
||||
user_id: res.json["user"]["id"].as_str().unwrap().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
140
backend/crates/api/tests/users.rs
Normal file
140
backend/crates/api/tests/users.rs
Normal file
@ -0,0 +1,140 @@
|
||||
//! WP-01: /api/users routes, admin only.
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{get, patch, post, test_app_with_admin};
|
||||
use serde_json::json;
|
||||
|
||||
const ADMIN: &str = "admin@example.com";
|
||||
const PW: &str = "admin-password-123";
|
||||
|
||||
fn new_user(email: &str) -> serde_json::Value {
|
||||
json!({"email": email, "display_name": "Test User", "password": "user-password-123", "role": "user"})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_create_list_get_and_update_users() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
|
||||
let res = post(&app, "/api/users", new_user("u@x.de"), Some(&token)).await;
|
||||
assert_eq!(res.status, StatusCode::CREATED, "{}", res.json);
|
||||
let id = res.json["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(res.json["role"], "user");
|
||||
assert_eq!(res.json["is_active"], true);
|
||||
assert!(res.json.get("password_hash").is_none());
|
||||
|
||||
let list = get(&app, "/api/users", Some(&token)).await;
|
||||
assert_eq!(list.json.as_array().unwrap().len(), 2);
|
||||
|
||||
let one = get(&app, &format!("/api/users/{id}"), Some(&token)).await;
|
||||
assert_eq!(one.json["email"], "u@x.de");
|
||||
|
||||
let upd = patch(
|
||||
&app,
|
||||
&format!("/api/users/{id}"),
|
||||
json!({"display_name": "Renamed", "role": "admin"}),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(upd.status, StatusCode::OK);
|
||||
assert_eq!(upd.json["display_name"], "Renamed");
|
||||
assert_eq!(upd.json["role"], "admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validation_and_conflict_errors_are_mapped() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
let res = post(&app, "/api/users", json!({"email": "bad", "display_name": "x", "password": "user-password-123", "role": "user"}), Some(&token)).await;
|
||||
assert_eq!(res.status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(res.json["error"], "validation");
|
||||
let res = post(&app, "/api/users", new_user(ADMIN), Some(&token)).await;
|
||||
assert_eq!(res.status, StatusCode::CONFLICT);
|
||||
assert_eq!(res.json["error"], "email_taken");
|
||||
let res = get(
|
||||
&app,
|
||||
"/api/users/00000000-0000-0000-0000-000000000000",
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_admin_cannot_be_deactivated() {
|
||||
let app = test_app_with_admin().await;
|
||||
let login = common::login(&app, ADMIN, PW).await;
|
||||
let res = patch(
|
||||
&app,
|
||||
&format!("/api/users/{}", login.user_id),
|
||||
json!({"is_active": false}),
|
||||
Some(&login.access),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::CONFLICT);
|
||||
assert_eq!(res.json["error"], "last_admin");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_can_reset_password_and_user_can_login_with_it() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
let id = post(&app, "/api/users", new_user("u@x.de"), Some(&token))
|
||||
.await
|
||||
.json["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let res = post(
|
||||
&app,
|
||||
&format!("/api/users/{id}/password"),
|
||||
json!({"password": "brand-new-password"}),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::NO_CONTENT);
|
||||
let res = post(
|
||||
&app,
|
||||
"/api/auth/login",
|
||||
json!({"email": "u@x.de", "password": "brand-new-password"}),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_admin_gets_403_on_user_management() {
|
||||
let app = test_app_with_admin().await;
|
||||
let admin = common::login(&app, ADMIN, PW).await.access;
|
||||
post(&app, "/api/users", new_user("u@x.de"), Some(&admin)).await;
|
||||
let user = common::login(&app, "u@x.de", "user-password-123")
|
||||
.await
|
||||
.access;
|
||||
|
||||
assert_eq!(
|
||||
get(&app, "/api/users", Some(&user)).await.status,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
post(&app, "/api/users", new_user("v@x.de"), Some(&user))
|
||||
.await
|
||||
.status,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
// but the user can see themself
|
||||
assert_eq!(
|
||||
get(&app, "/api/auth/me", Some(&user)).await.json["email"],
|
||||
"u@x.de"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthenticated_requests_are_401() {
|
||||
let app = test_app_with_admin().await;
|
||||
assert_eq!(
|
||||
get(&app, "/api/users", None).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user