diff --git a/backend/crates/api/tests/auth.rs b/backend/crates/api/tests/auth.rs new file mode 100644 index 0000000..5031c3d --- /dev/null +++ b/backend/crates/api/tests/auth.rs @@ -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); +} diff --git a/backend/crates/api/tests/common/mod.rs b/backend/crates/api/tests/common/mod.rs index ec3fb36..5f5d08b 100644 --- a/backend/crates/api/tests/common/mod.rs +++ b/backend/crates/api/tests/common/mod.rs @@ -87,3 +87,28 @@ pub fn cookie_value(res: &Res, name: &str) -> Option { .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(), + } +} diff --git a/backend/crates/api/tests/users.rs b/backend/crates/api/tests/users.rs new file mode 100644 index 0000000..28cb68d --- /dev/null +++ b/backend/crates/api/tests/users.rs @@ -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 + ); +} diff --git a/backend/crates/application/src/auth_service.rs b/backend/crates/application/src/auth_service.rs new file mode 100644 index 0000000..9566827 --- /dev/null +++ b/backend/crates/application/src/auth_service.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use chrono::Duration; +use domain::auth::TokenPair; +use domain::ports::{ + AccessTokenIssuer, AuditLog, PasswordHasher, RefreshTokenRepository, UserRepository, +}; +use domain::user::User; +use domain::DomainError; + +pub const REFRESH_TOKEN_TTL_DAYS: i64 = 30; + +pub struct AuthService { + pub(crate) users: Arc, + pub(crate) refresh: Arc, + pub(crate) audit: Arc, + pub(crate) hasher: Arc, + pub(crate) tokens: Arc, + pub(crate) refresh_ttl: Duration, +} + +impl AuthService { + pub fn new( + users: Arc, + refresh: Arc, + audit: Arc, + hasher: Arc, + tokens: Arc, + ) -> Self { + Self { + users, + refresh, + audit, + hasher, + tokens, + refresh_ttl: Duration::days(REFRESH_TOKEN_TTL_DAYS), + } + } + + pub async fn login( + &self, + _email: &str, + _password: &str, + _ip: Option, + ) -> Result { + todo!() + } + + pub async fn refresh( + &self, + _refresh_token: &str, + _ip: Option, + ) -> Result { + todo!() + } + + pub async fn logout(&self, _refresh_token: &str) -> Result<(), DomainError> { + todo!() + } + + /// Resolve the user behind an access token; fails for invalid tokens and inactive users. + pub async fn authenticate(&self, _access_token: &str) -> Result { + todo!() + } +} diff --git a/backend/crates/application/src/lib.rs b/backend/crates/application/src/lib.rs index 1d2d3c5..8a75c52 100644 --- a/backend/crates/application/src/lib.rs +++ b/backend/crates/application/src/lib.rs @@ -1 +1,11 @@ -//! application layer +//! Application layer: use cases orchestrating the domain through its ports. +pub mod auth_service; +pub mod user_service; + +pub use auth_service::AuthService; +pub use user_service::UserService; + +#[cfg(test)] +pub(crate) mod test_fakes; +#[cfg(test)] +mod tests; diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs new file mode 100644 index 0000000..315527e --- /dev/null +++ b/backend/crates/application/src/test_fakes.rs @@ -0,0 +1,187 @@ +//! In-memory fakes for the ports, used by the unit tests of the use cases. +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::Utc; +use domain::auth::{AccessClaims, AuthEvent, RefreshToken}; +use domain::ports::*; +use domain::user::{Role, User, UserUpdate}; +use domain::DomainError; +use uuid::Uuid; + +#[derive(Default)] +pub struct MemUsers(pub Mutex>); + +#[async_trait] +impl UserRepository for MemUsers { + async fn find_by_id(&self, id: Uuid) -> Result, DomainError> { + Ok(self.0.lock().unwrap().get(&id).cloned()) + } + async fn find_by_email(&self, email: &str) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .values() + .find(|u| u.email == email) + .cloned()) + } + async fn list(&self) -> Result, DomainError> { + let mut v: Vec<_> = self.0.lock().unwrap().values().cloned().collect(); + v.sort_by(|a, b| a.email.cmp(&b.email)); + Ok(v) + } + async fn count(&self) -> Result { + Ok(self.0.lock().unwrap().len() as u64) + } + async fn count_active_admins(&self) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .values() + .filter(|u| u.is_admin() && u.is_active) + .count() as u64) + } + async fn insert(&self, user: &User) -> Result<(), DomainError> { + self.0.lock().unwrap().insert(user.id, user.clone()); + Ok(()) + } + async fn update(&self, id: Uuid, update: &UserUpdate) -> Result { + let mut m = self.0.lock().unwrap(); + let u = m.get_mut(&id).ok_or(DomainError::NotFound)?; + if let Some(n) = &update.display_name { + u.display_name = n.clone(); + } + if let Some(r) = update.role { + u.role = r; + } + if let Some(a) = update.is_active { + u.is_active = a; + } + Ok(u.clone()) + } + async fn set_password_hash(&self, id: Uuid, hash: &str) -> Result<(), DomainError> { + let mut m = self.0.lock().unwrap(); + m.get_mut(&id).ok_or(DomainError::NotFound)?.password_hash = hash.into(); + Ok(()) + } +} + +#[derive(Default)] +pub struct MemRefresh(pub Mutex>); + +#[async_trait] +impl RefreshTokenRepository for MemRefresh { + async fn insert(&self, token: &RefreshToken) -> Result<(), DomainError> { + self.0.lock().unwrap().push(token.clone()); + Ok(()) + } + async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { + Ok(self + .0 + .lock() + .unwrap() + .iter() + .find(|t| t.token_hash == hash) + .cloned()) + } + async fn revoke(&self, id: Uuid) -> Result<(), DomainError> { + self.0 + .lock() + .unwrap() + .iter_mut() + .filter(|t| t.id == id) + .for_each(|t| t.revoked = true); + Ok(()) + } + async fn revoke_family(&self, family: Uuid) -> Result<(), DomainError> { + self.0 + .lock() + .unwrap() + .iter_mut() + .filter(|t| t.family == family) + .for_each(|t| t.revoked = true); + Ok(()) + } +} + +#[derive(Default)] +pub struct MemAudit(pub Mutex>); + +#[async_trait] +impl AuditLog for MemAudit { + async fn record(&self, event: &AuthEvent) -> Result<(), DomainError> { + self.0.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +/// "Hashes" by prefixing; good enough to test the flow without Argon2 cost. +pub struct FakeHasher; +impl PasswordHasher for FakeHasher { + fn hash(&self, password: &str) -> Result { + Ok(format!("hashed:{password}")) + } + fn verify(&self, password: &str, hash: &str) -> bool { + hash == format!("hashed:{password}") + } +} + +/// Access tokens are `":"`; anything else is invalid. +pub struct FakeTokens; +impl AccessTokenIssuer for FakeTokens { + fn issue(&self, user: &User) -> Result { + Ok(format!("{}:{}", user.id, user.role.as_str())) + } + fn verify(&self, token: &str) -> Result { + let (id, role) = token.split_once(':').ok_or(DomainError::InvalidToken)?; + Ok(AccessClaims { + sub: id.parse().map_err(|_| DomainError::InvalidToken)?, + role: Role::parse(role).ok_or(DomainError::InvalidToken)?, + exp: 0, + }) + } +} + +pub fn user(email: &str, password: &str, role: Role, active: bool) -> User { + User { + id: Uuid::new_v4(), + email: email.into(), + display_name: email.split('@').next().unwrap().into(), + password_hash: format!("hashed:{password}"), + role, + is_active: active, + created_at: Utc::now(), + } +} + +pub struct Fixture { + pub users: Arc, + pub refresh: Arc, + pub audit: Arc, + pub auth: crate::AuthService, + pub svc: crate::UserService, +} + +pub fn fixture() -> Fixture { + let users = Arc::new(MemUsers::default()); + let refresh = Arc::new(MemRefresh::default()); + let audit = Arc::new(MemAudit::default()); + let auth = crate::AuthService::new( + users.clone(), + refresh.clone(), + audit.clone(), + Arc::new(FakeHasher), + Arc::new(FakeTokens), + ); + let svc = crate::UserService::new(users.clone(), Arc::new(FakeHasher)); + Fixture { + users, + refresh, + audit, + auth, + svc, + } +} diff --git a/backend/crates/application/src/tests/auth_service_tests.rs b/backend/crates/application/src/tests/auth_service_tests.rs new file mode 100644 index 0000000..fdc4140 --- /dev/null +++ b/backend/crates/application/src/tests/auth_service_tests.rs @@ -0,0 +1,175 @@ +use chrono::{Duration, Utc}; +use domain::auth::AuthEventKind; +use domain::user::Role; +use domain::DomainError; + +use crate::test_fakes::{fixture, user}; +use domain::ports::UserRepository; + +const PW: &str = "correct-horse-battery"; + +#[tokio::test] +async fn login_with_valid_credentials_returns_token_pair_and_audits() { + let f = fixture(); + let u = user("a@x.de", PW, Role::Admin, true); + f.users.insert(&u).await.unwrap(); + + let pair = f + .auth + .login("a@x.de", PW, Some("1.2.3.4".into())) + .await + .unwrap(); + + assert_eq!(pair.access_token, format!("{}:admin", u.id)); + assert!(pair.refresh_token.len() >= 32); + assert_eq!(f.refresh.0.lock().unwrap().len(), 1); + let events = f.audit.0.lock().unwrap(); + assert_eq!(events[0].kind, AuthEventKind::LoginSuccess); + assert_eq!(events[0].user_id, Some(u.id)); + assert_eq!(events[0].ip.as_deref(), Some("1.2.3.4")); +} + +#[tokio::test] +async fn login_with_wrong_password_fails_and_audits() { + let f = fixture(); + f.users + .insert(&user("a@x.de", PW, Role::User, true)) + .await + .unwrap(); + + let err = f + .auth + .login("a@x.de", "wrong-password-123", None) + .await + .unwrap_err(); + + assert_eq!(err, DomainError::InvalidCredentials); + assert_eq!( + f.audit.0.lock().unwrap()[0].kind, + AuthEventKind::LoginFailed + ); +} + +#[tokio::test] +async fn login_with_unknown_email_fails_with_same_error() { + let f = fixture(); + let err = f.auth.login("nobody@x.de", PW, None).await.unwrap_err(); + assert_eq!(err, DomainError::InvalidCredentials); +} + +#[tokio::test] +async fn login_of_inactive_user_is_rejected() { + let f = fixture(); + f.users + .insert(&user("a@x.de", PW, Role::User, false)) + .await + .unwrap(); + let err = f.auth.login("a@x.de", PW, None).await.unwrap_err(); + assert_eq!(err, DomainError::InactiveUser); +} + +#[tokio::test] +async fn refresh_rotates_token_and_invalidates_the_old_one() { + let f = fixture(); + f.users + .insert(&user("a@x.de", PW, Role::User, true)) + .await + .unwrap(); + let first = f.auth.login("a@x.de", PW, None).await.unwrap(); + + let second = f.auth.refresh(&first.refresh_token, None).await.unwrap(); + assert_ne!(second.refresh_token, first.refresh_token); + + // old token is now revoked -> reuse is detected and the whole family is revoked + let err = f + .auth + .refresh(&first.refresh_token, None) + .await + .unwrap_err(); + assert_eq!(err, DomainError::InvalidToken); + let err = f + .auth + .refresh(&second.refresh_token, None) + .await + .unwrap_err(); + assert_eq!(err, DomainError::InvalidToken); + let kinds: Vec<_> = f.audit.0.lock().unwrap().iter().map(|e| e.kind).collect(); + assert!(kinds.contains(&AuthEventKind::RefreshReuseDetected)); +} + +#[tokio::test] +async fn refresh_with_unknown_or_expired_token_fails() { + let f = fixture(); + f.users + .insert(&user("a@x.de", PW, Role::User, true)) + .await + .unwrap(); + assert_eq!( + f.auth.refresh("garbage", None).await.unwrap_err(), + DomainError::InvalidToken + ); + + let pair = f.auth.login("a@x.de", PW, None).await.unwrap(); + f.refresh.0.lock().unwrap()[0].expires_at = Utc::now() - Duration::minutes(1); + assert_eq!( + f.auth.refresh(&pair.refresh_token, None).await.unwrap_err(), + DomainError::InvalidToken + ); +} + +#[tokio::test] +async fn refresh_fails_for_deactivated_user() { + let f = fixture(); + let u = user("a@x.de", PW, Role::User, true); + f.users.insert(&u).await.unwrap(); + let pair = f.auth.login("a@x.de", PW, None).await.unwrap(); + f.users.0.lock().unwrap().get_mut(&u.id).unwrap().is_active = false; + assert_eq!( + f.auth.refresh(&pair.refresh_token, None).await.unwrap_err(), + DomainError::InactiveUser + ); +} + +#[tokio::test] +async fn logout_revokes_refresh_token() { + let f = fixture(); + f.users + .insert(&user("a@x.de", PW, Role::User, true)) + .await + .unwrap(); + let pair = f.auth.login("a@x.de", PW, None).await.unwrap(); + f.auth.logout(&pair.refresh_token).await.unwrap(); + assert_eq!( + f.auth.refresh(&pair.refresh_token, None).await.unwrap_err(), + DomainError::InvalidToken + ); + // logging out an unknown token is not an error (idempotent) + f.auth.logout("unknown").await.unwrap(); +} + +#[tokio::test] +async fn authenticate_resolves_user_from_access_token() { + let f = fixture(); + let u = user("a@x.de", PW, Role::Admin, true); + f.users.insert(&u).await.unwrap(); + let pair = f.auth.login("a@x.de", PW, None).await.unwrap(); + let me = f.auth.authenticate(&pair.access_token).await.unwrap(); + assert_eq!(me.id, u.id); + assert_eq!( + f.auth.authenticate("bad").await.unwrap_err(), + DomainError::InvalidToken + ); +} + +#[tokio::test] +async fn authenticate_rejects_deactivated_user() { + let f = fixture(); + let u = user("a@x.de", PW, Role::User, true); + f.users.insert(&u).await.unwrap(); + let pair = f.auth.login("a@x.de", PW, None).await.unwrap(); + f.users.0.lock().unwrap().get_mut(&u.id).unwrap().is_active = false; + assert_eq!( + f.auth.authenticate(&pair.access_token).await.unwrap_err(), + DomainError::InactiveUser + ); +} diff --git a/backend/crates/application/src/tests/mod.rs b/backend/crates/application/src/tests/mod.rs new file mode 100644 index 0000000..adce4e6 --- /dev/null +++ b/backend/crates/application/src/tests/mod.rs @@ -0,0 +1,2 @@ +mod auth_service_tests; +mod user_service_tests; diff --git a/backend/crates/application/src/tests/user_service_tests.rs b/backend/crates/application/src/tests/user_service_tests.rs new file mode 100644 index 0000000..e1b88c7 --- /dev/null +++ b/backend/crates/application/src/tests/user_service_tests.rs @@ -0,0 +1,170 @@ +use domain::user::{NewUser, Role, UserUpdate}; +use domain::DomainError; +use uuid::Uuid; + +use crate::test_fakes::{fixture, user}; +use domain::ports::UserRepository; + +fn new_user(email: &str, role: Role) -> NewUser { + NewUser { + email: email.into(), + display_name: "Someone".into(), + password: "a-long-password-1".into(), + role, + } +} + +#[tokio::test] +async fn create_hashes_password_and_lists_users_sorted_by_email() { + let f = fixture(); + let b = f.svc.create(new_user("b@x.de", Role::User)).await.unwrap(); + let a = f.svc.create(new_user("a@x.de", Role::Admin)).await.unwrap(); + assert_eq!(b.password_hash, "hashed:a-long-password-1"); + assert!(b.is_active); + let list = f.svc.list().await.unwrap(); + assert_eq!( + list.iter().map(|u| u.id).collect::>(), + vec![a.id, b.id] + ); +} + +#[tokio::test] +async fn create_normalises_email_and_rejects_duplicates() { + let f = fixture(); + f.svc.create(new_user("A@X.de", Role::User)).await.unwrap(); + assert_eq!(f.svc.list().await.unwrap()[0].email, "a@x.de"); + let err = f + .svc + .create(new_user("a@x.de", Role::User)) + .await + .unwrap_err(); + assert_eq!(err, DomainError::EmailTaken); +} + +#[tokio::test] +async fn create_validates_email_and_password() { + let f = fixture(); + let mut bad = new_user("not-an-email", Role::User); + assert!(matches!( + f.svc.create(bad.clone()).await.unwrap_err(), + DomainError::Validation(_) + )); + bad.email = "ok@x.de".into(); + bad.password = "short".into(); + assert!(matches!( + f.svc.create(bad).await.unwrap_err(), + DomainError::Validation(_) + )); +} + +#[tokio::test] +async fn get_unknown_user_is_not_found() { + let f = fixture(); + assert_eq!( + f.svc.get(Uuid::new_v4()).await.unwrap_err(), + DomainError::NotFound + ); +} + +#[tokio::test] +async fn update_changes_name_role_and_active_flag() { + let f = fixture(); + f.users + .insert(&user("admin@x.de", "pw", Role::Admin, true)) + .await + .unwrap(); + let u = f.svc.create(new_user("u@x.de", Role::User)).await.unwrap(); + let updated = f + .svc + .update( + u.id, + UserUpdate { + display_name: Some("New".into()), + role: Some(Role::Admin), + is_active: Some(false), + }, + ) + .await + .unwrap(); + assert_eq!(updated.display_name, "New"); + assert_eq!(updated.role, Role::Admin); + assert!(!updated.is_active); +} + +#[tokio::test] +async fn last_active_admin_cannot_be_demoted_or_deactivated() { + let f = fixture(); + let admin = user("admin@x.de", "pw", Role::Admin, true); + f.users.insert(&admin).await.unwrap(); + + let demote = UserUpdate { + role: Some(Role::User), + ..Default::default() + }; + assert_eq!( + f.svc.update(admin.id, demote).await.unwrap_err(), + DomainError::LastAdmin + ); + let deactivate = UserUpdate { + is_active: Some(false), + ..Default::default() + }; + assert_eq!( + f.svc + .update(admin.id, deactivate.clone()) + .await + .unwrap_err(), + DomainError::LastAdmin + ); + + // with a second active admin it is allowed + f.users + .insert(&user("admin2@x.de", "pw", Role::Admin, true)) + .await + .unwrap(); + assert!(!f.svc.update(admin.id, deactivate).await.unwrap().is_active); +} + +#[tokio::test] +async fn reset_password_validates_and_stores_new_hash() { + let f = fixture(); + let u = f.svc.create(new_user("u@x.de", Role::User)).await.unwrap(); + assert!(matches!( + f.svc.reset_password(u.id, "short").await.unwrap_err(), + DomainError::Validation(_) + )); + f.svc + .reset_password(u.id, "another-long-password") + .await + .unwrap(); + assert_eq!( + f.svc.get(u.id).await.unwrap().password_hash, + "hashed:another-long-password" + ); + assert_eq!( + f.svc + .reset_password(Uuid::new_v4(), "another-long-password") + .await + .unwrap_err(), + DomainError::NotFound + ); +} + +#[tokio::test] +async fn bootstrap_admin_only_creates_when_no_user_exists() { + let f = fixture(); + assert!(f + .svc + .bootstrap_admin("root@x.de", "bootstrap-password") + .await + .unwrap()); + let list = f.svc.list().await.unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].role, Role::Admin); + assert!(!f + .svc + .bootstrap_admin("other@x.de", "bootstrap-password") + .await + .unwrap()); + assert_eq!(f.svc.list().await.unwrap().len(), 1); +} diff --git a/backend/crates/application/src/user_service.rs b/backend/crates/application/src/user_service.rs new file mode 100644 index 0000000..dc91c15 --- /dev/null +++ b/backend/crates/application/src/user_service.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use domain::ports::{PasswordHasher, UserRepository}; +use domain::user::{NewUser, User, UserUpdate}; +use domain::DomainError; +use uuid::Uuid; + +pub struct UserService { + pub(crate) users: Arc, + pub(crate) hasher: Arc, +} + +impl UserService { + pub fn new(users: Arc, hasher: Arc) -> Self { + Self { users, hasher } + } + + pub async fn list(&self) -> Result, DomainError> { + todo!() + } + + pub async fn get(&self, _id: Uuid) -> Result { + todo!() + } + + pub async fn create(&self, _new: NewUser) -> Result { + todo!() + } + + pub async fn update(&self, _id: Uuid, _update: UserUpdate) -> Result { + todo!() + } + + pub async fn reset_password(&self, _id: Uuid, _password: &str) -> Result<(), DomainError> { + todo!() + } + + /// Create the initial admin if the user table is empty. Returns true if created. + pub async fn bootstrap_admin( + &self, + _email: &str, + _password: &str, + ) -> Result { + todo!() + } +} diff --git a/backend/crates/domain/src/auth.rs b/backend/crates/domain/src/auth.rs new file mode 100644 index 0000000..a2b3b3f --- /dev/null +++ b/backend/crates/domain/src/auth.rs @@ -0,0 +1,67 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::user::Role; + +/// A stored refresh token. Only the SHA-256 hash of the opaque token is persisted. +/// Tokens issued by rotation share a `family`; reuse of a revoked token revokes the family. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefreshToken { + pub id: Uuid, + pub user_id: Uuid, + pub family: Uuid, + pub token_hash: String, + pub expires_at: DateTime, + pub revoked: bool, +} + +impl RefreshToken { + pub fn is_valid(&self, now: DateTime) -> bool { + !self.revoked && self.expires_at > now + } +} + +/// Claims carried by a short-lived access token. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccessClaims { + pub sub: Uuid, + pub role: Role, + pub exp: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TokenPair { + pub access_token: String, + pub refresh_token: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthEventKind { + LoginSuccess, + LoginFailed, + Refresh, + Logout, + RefreshReuseDetected, +} + +impl AuthEventKind { + pub fn as_str(self) -> &'static str { + match self { + AuthEventKind::LoginSuccess => "login_success", + AuthEventKind::LoginFailed => "login_failed", + AuthEventKind::Refresh => "refresh", + AuthEventKind::Logout => "logout", + AuthEventKind::RefreshReuseDetected => "refresh_reuse_detected", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthEvent { + pub user_id: Option, + pub email: String, + pub kind: AuthEventKind, + pub ip: Option, + pub at: DateTime, +} diff --git a/backend/crates/domain/src/error.rs b/backend/crates/domain/src/error.rs new file mode 100644 index 0000000..57033a1 --- /dev/null +++ b/backend/crates/domain/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum DomainError { + #[error("not found")] + NotFound, + #[error("email already in use")] + EmailTaken, + #[error("invalid credentials")] + InvalidCredentials, + #[error("user is inactive")] + InactiveUser, + #[error("invalid or expired token")] + InvalidToken, + #[error("the last active admin cannot be demoted or deactivated")] + LastAdmin, + #[error("validation failed: {0}")] + Validation(String), + #[error("storage error: {0}")] + Storage(String), +} diff --git a/backend/crates/domain/src/lib.rs b/backend/crates/domain/src/lib.rs index 355ec84..2b4fb0e 100644 --- a/backend/crates/domain/src/lib.rs +++ b/backend/crates/domain/src/lib.rs @@ -1 +1,8 @@ -//! domain layer +//! Domain layer: entities, value objects, errors and the ports (traits) the application +//! layer depends on. No I/O here. +pub mod auth; +pub mod error; +pub mod ports; +pub mod user; + +pub use error::DomainError; diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs new file mode 100644 index 0000000..1f09527 --- /dev/null +++ b/backend/crates/domain/src/ports.rs @@ -0,0 +1,42 @@ +//! Ports implemented by the infrastructure layer. +use async_trait::async_trait; +use uuid::Uuid; + +use crate::auth::{AccessClaims, AuthEvent, RefreshToken}; +use crate::user::{User, UserUpdate}; +use crate::DomainError; + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: Uuid) -> Result, DomainError>; + async fn find_by_email(&self, email: &str) -> Result, DomainError>; + async fn list(&self) -> Result, DomainError>; + async fn count(&self) -> Result; + async fn count_active_admins(&self) -> Result; + async fn insert(&self, user: &User) -> Result<(), DomainError>; + async fn update(&self, id: Uuid, update: &UserUpdate) -> Result; + async fn set_password_hash(&self, id: Uuid, hash: &str) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait RefreshTokenRepository: Send + Sync { + async fn insert(&self, token: &RefreshToken) -> Result<(), DomainError>; + async fn find_by_hash(&self, hash: &str) -> Result, DomainError>; + async fn revoke(&self, id: Uuid) -> Result<(), DomainError>; + async fn revoke_family(&self, family: Uuid) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait AuditLog: Send + Sync { + async fn record(&self, event: &AuthEvent) -> Result<(), DomainError>; +} + +pub trait PasswordHasher: Send + Sync { + fn hash(&self, password: &str) -> Result; + fn verify(&self, password: &str, hash: &str) -> bool; +} + +pub trait AccessTokenIssuer: Send + Sync { + fn issue(&self, user: &User) -> Result; + fn verify(&self, token: &str) -> Result; +} diff --git a/backend/crates/domain/src/user.rs b/backend/crates/domain/src/user.rs new file mode 100644 index 0000000..efea22c --- /dev/null +++ b/backend/crates/domain/src/user.rs @@ -0,0 +1,82 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Admin, + User, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::Admin => "admin", + Role::User => "user", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "admin" => Some(Role::Admin), + "user" => Some(Role::User), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct User { + pub id: Uuid, + pub email: String, + pub display_name: String, + pub password_hash: String, + pub role: Role, + pub is_active: bool, + pub created_at: DateTime, +} + +impl User { + pub fn is_admin(&self) -> bool { + self.role == Role::Admin + } +} + +/// Input for creating a user; the password is still in plain text here. +#[derive(Clone, Debug)] +pub struct NewUser { + pub email: String, + pub display_name: String, + pub password: String, + pub role: Role, +} + +/// Partial update; `None` keeps the current value. +#[derive(Clone, Debug, Default)] +pub struct UserUpdate { + pub display_name: Option, + pub role: Option, + pub is_active: Option, +} + +pub const MIN_PASSWORD_LEN: usize = 12; + +pub fn validate_email(email: &str) -> Result<(), crate::DomainError> { + let ok = email.contains('@') + && !email.starts_with('@') + && !email.ends_with('@') + && !email.contains(' '); + ok.then_some(()) + .ok_or_else(|| crate::DomainError::Validation("invalid email".into())) +} + +pub fn validate_password(password: &str) -> Result<(), crate::DomainError> { + (password.chars().count() >= MIN_PASSWORD_LEN) + .then_some(()) + .ok_or_else(|| { + crate::DomainError::Validation(format!( + "password must have at least {MIN_PASSWORD_LEN} characters" + )) + }) +} diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..b738dc4 --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, type Page } from '@playwright/test' + +const ADMIN = { email: 'admin@example.com', password: 'admin-password-123' } + +async function login(page: Page, email: string, password: string) { + await page.goto('/login') + await page.getByLabel('Email').fill(email) + await page.getByLabel('Password').fill(password) + await page.getByRole('button', { name: 'Sign in' }).click() +} + +test('unauthenticated visitor is redirected to login', async ({ page }) => { + await page.goto('/') + await expect(page).toHaveURL(/\/login/) +}) + +test('wrong password shows an error', async ({ page }) => { + await login(page, ADMIN.email, 'wrong-password-xx') + await expect(page.getByRole('alert')).toContainText('Invalid email or password') +}) + +test('admin logs in, manages users, logs out; user has no admin access', async ({ page }) => { + await login(page, ADMIN.email, ADMIN.password) + await expect(page).toHaveURL('/') + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + + // create a user + await page.getByRole('link', { name: 'Users' }).click() + await page.getByRole('button', { name: 'New user' }).click() + const email = `e2e-${Date.now()}@example.com` + await page.getByLabel('Email').fill(email) + await page.getByLabel('Display name').fill('E2E User') + await page.getByLabel('Password').fill('user-password-123') + await page.getByRole('button', { name: 'Create' }).click() + const row = page.getByRole('row', { name: new RegExp(email) }) + await expect(row).toBeVisible() + + // edit: rename + await row.getByRole('button', { name: 'Edit' }).click() + await page.getByLabel('Display name').fill('Renamed User') + await page.getByRole('button', { name: 'Save' }).click() + await expect(row).toContainText('Renamed User') + + // logout + await page.getByRole('button', { name: 'Sign out' }).click() + await expect(page).toHaveURL(/\/login/) + + // the new user can log in but not manage users + await login(page, email, 'user-password-123') + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() + await expect(page.getByRole('link', { name: 'Users' })).toHaveCount(0) + await page.goto('/users') + await expect(page.getByText('You do not have permission')).toBeVisible() +}) diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 23ac945..ddf9c38 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test' -test('app loads and shows backend health', async ({ page }) => { - await page.goto('/') +test('login page loads', async ({ page }) => { + await page.goto('/login') await expect(page.getByText('SoftVisor Monitoring')).toBeVisible() - await expect(page.getByTestId('backend-status')).toContainText('ok') + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible() }) diff --git a/frontend/src/components/UserForm.test.ts b/frontend/src/components/UserForm.test.ts new file mode 100644 index 0000000..dc2a13d --- /dev/null +++ b/frontend/src/components/UserForm.test.ts @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils' +import UserForm from './UserForm.vue' + +describe('UserForm', () => { + it('emits the new user payload in create mode', async () => { + const w = mount(UserForm, { props: { mode: 'create' } }) + await w.find('input[name=email]').setValue('u@x.de') + await w.find('input[name=display_name]').setValue('User') + await w.find('input[name=password]').setValue('user-password-123') + await w.find('select[name=role]').setValue('admin') + await w.find('form').trigger('submit') + expect(w.emitted('submit')![0][0]).toEqual({ + email: 'u@x.de', + display_name: 'User', + password: 'user-password-123', + role: 'admin', + }) + }) + + it('rejects short passwords client-side', async () => { + const w = mount(UserForm, { props: { mode: 'create' } }) + await w.find('input[name=email]').setValue('u@x.de') + await w.find('input[name=display_name]').setValue('User') + await w.find('input[name=password]').setValue('short') + await w.find('form').trigger('submit') + expect(w.emitted('submit')).toBeUndefined() + expect(w.text()).toContain('at least 12 characters') + }) + + it('hides email and password in edit mode and prefills values', () => { + const w = mount(UserForm, { + props: { + mode: 'edit', + user: { id: '1', email: 'u@x.de', display_name: 'User', role: 'user', is_active: false }, + }, + }) + expect(w.find('input[name=email]').exists()).toBe(false) + expect(w.find('input[name=password]').exists()).toBe(false) + expect((w.find('input[name=display_name]').element as HTMLInputElement).value).toBe('User') + expect((w.find('input[name=is_active]').element as HTMLInputElement).checked).toBe(false) + }) +}) diff --git a/frontend/src/pages/HealthPage.vue b/frontend/src/pages/HealthPage.vue deleted file mode 100644 index 81ff2f3..0000000 --- a/frontend/src/pages/HealthPage.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/frontend/src/pages/LoginPage.test.ts b/frontend/src/pages/LoginPage.test.ts new file mode 100644 index 0000000..4a7fa6e --- /dev/null +++ b/frontend/src/pages/LoginPage.test.ts @@ -0,0 +1,38 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import LoginPage from './LoginPage.vue' +import { useAuthStore } from '../stores/auth' + +const push = vi.fn() +vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) })) + +beforeEach(() => { + setActivePinia(createPinia()) + push.mockReset() +}) + +describe('LoginPage', () => { + it('submits credentials and navigates to the dashboard', async () => { + const store = useAuthStore() + store.login = vi.fn().mockResolvedValue(undefined) + const w = mount(LoginPage) + await w.find('input[type=email]').setValue('a@x.de') + await w.find('input[type=password]').setValue('secret-password') + await w.find('form').trigger('submit') + await flushPromises() + expect(store.login).toHaveBeenCalledWith('a@x.de', 'secret-password') + expect(push).toHaveBeenCalledWith('/') + }) + + it('shows an error message on invalid credentials', async () => { + const store = useAuthStore() + store.login = vi.fn().mockRejectedValue({ code: 'invalid_credentials', message: 'x' }) + const w = mount(LoginPage) + await w.find('input[type=email]').setValue('a@x.de') + await w.find('input[type=password]').setValue('wrong') + await w.find('form').trigger('submit') + await flushPromises() + expect(w.text()).toContain('Invalid email or password') + expect(push).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/stores/auth.test.ts b/frontend/src/stores/auth.test.ts new file mode 100644 index 0000000..1968d2a --- /dev/null +++ b/frontend/src/stores/auth.test.ts @@ -0,0 +1,74 @@ +import { setActivePinia, createPinia } from 'pinia' +import { useAuthStore } from './auth' +import { api } from '../api/client' + +const admin = { id: '1', email: 'a@x.de', display_name: 'A', role: 'admin', is_active: true } + +function mockFetch(responses: Array<{ status: number; body?: unknown }>) { + const calls: Array<{ url: string; init: RequestInit }> = [] + let i = 0 + globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + const r = responses[Math.min(i++, responses.length - 1)] + return new Response(r.body === undefined ? null : JSON.stringify(r.body), { + status: r.status, + headers: { 'content-type': 'application/json' }, + }) + }) as unknown as typeof fetch + return calls +} + +beforeEach(() => setActivePinia(createPinia())) + +describe('auth store', () => { + it('login stores access token and user', async () => { + mockFetch([{ status: 200, body: { access_token: 'tok', user: admin } }]) + const store = useAuthStore() + await store.login('a@x.de', 'pw') + expect(store.accessToken).toBe('tok') + expect(store.user?.email).toBe('a@x.de') + expect(store.isAdmin).toBe(true) + }) + + it('login failure throws with the API error code', async () => { + mockFetch([{ status: 401, body: { error: 'invalid_credentials', message: 'nope' } }]) + const store = useAuthStore() + await expect(store.login('a@x.de', 'pw')).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(store.accessToken).toBeNull() + }) + + it('api client retries once with a refreshed token on 401', async () => { + const calls = mockFetch([ + { status: 401, body: { error: 'unauthorized' } }, + { status: 200, body: { access_token: 'fresh', user: admin } }, + { status: 200, body: [admin] }, + ]) + const store = useAuthStore() + store.accessToken = 'stale' + const users = await api.get('/api/users') + expect(users).toHaveLength(1) + expect(store.accessToken).toBe('fresh') + expect(calls[1].url).toBe('/api/auth/refresh') + expect((calls[2].init.headers as Record)['Authorization']).toBe('Bearer fresh') + }) + + it('logs out when the refresh fails', async () => { + mockFetch([{ status: 401 }, { status: 401, body: { error: 'invalid_token' } }]) + const store = useAuthStore() + store.accessToken = 'stale' + store.user = admin + await expect(api.get('/api/users')).rejects.toMatchObject({ status: 401 }) + expect(store.accessToken).toBeNull() + expect(store.user).toBeNull() + }) + + it('logout calls the API and clears state', async () => { + const calls = mockFetch([{ status: 204 }]) + const store = useAuthStore() + store.accessToken = 'tok' + store.user = admin + await store.logout() + expect(calls[0].url).toBe('/api/auth/logout') + expect(store.user).toBeNull() + }) +})