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}=")))
|
.find(|c| c.starts_with(&format!("{name}=")))
|
||||||
.map(|c| c.split(';').next().unwrap().to_string())
|
.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
|
||||||
|
);
|
||||||
|
}
|
||||||
65
backend/crates/application/src/auth_service.rs
Normal file
65
backend/crates/application/src/auth_service.rs
Normal file
@ -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<dyn UserRepository>,
|
||||||
|
pub(crate) refresh: Arc<dyn RefreshTokenRepository>,
|
||||||
|
pub(crate) audit: Arc<dyn AuditLog>,
|
||||||
|
pub(crate) hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub(crate) tokens: Arc<dyn AccessTokenIssuer>,
|
||||||
|
pub(crate) refresh_ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService {
|
||||||
|
pub fn new(
|
||||||
|
users: Arc<dyn UserRepository>,
|
||||||
|
refresh: Arc<dyn RefreshTokenRepository>,
|
||||||
|
audit: Arc<dyn AuditLog>,
|
||||||
|
hasher: Arc<dyn PasswordHasher>,
|
||||||
|
tokens: Arc<dyn AccessTokenIssuer>,
|
||||||
|
) -> 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<String>,
|
||||||
|
) -> Result<TokenPair, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh(
|
||||||
|
&self,
|
||||||
|
_refresh_token: &str,
|
||||||
|
_ip: Option<String>,
|
||||||
|
) -> Result<TokenPair, DomainError> {
|
||||||
|
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<User, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
187
backend/crates/application/src/test_fakes.rs
Normal file
187
backend/crates/application/src/test_fakes.rs
Normal file
@ -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<HashMap<Uuid, User>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for MemUsers {
|
||||||
|
async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, DomainError> {
|
||||||
|
Ok(self.0.lock().unwrap().get(&id).cloned())
|
||||||
|
}
|
||||||
|
async fn find_by_email(&self, email: &str) -> Result<Option<User>, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.find(|u| u.email == email)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
async fn list(&self) -> Result<Vec<User>, 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<u64, DomainError> {
|
||||||
|
Ok(self.0.lock().unwrap().len() as u64)
|
||||||
|
}
|
||||||
|
async fn count_active_admins(&self) -> Result<u64, DomainError> {
|
||||||
|
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<User, DomainError> {
|
||||||
|
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<Vec<RefreshToken>>);
|
||||||
|
|
||||||
|
#[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<Option<RefreshToken>, 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<Vec<AuthEvent>>);
|
||||||
|
|
||||||
|
#[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<String, DomainError> {
|
||||||
|
Ok(format!("hashed:{password}"))
|
||||||
|
}
|
||||||
|
fn verify(&self, password: &str, hash: &str) -> bool {
|
||||||
|
hash == format!("hashed:{password}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access tokens are `"<uuid>:<role>"`; anything else is invalid.
|
||||||
|
pub struct FakeTokens;
|
||||||
|
impl AccessTokenIssuer for FakeTokens {
|
||||||
|
fn issue(&self, user: &User) -> Result<String, DomainError> {
|
||||||
|
Ok(format!("{}:{}", user.id, user.role.as_str()))
|
||||||
|
}
|
||||||
|
fn verify(&self, token: &str) -> Result<AccessClaims, DomainError> {
|
||||||
|
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<MemUsers>,
|
||||||
|
pub refresh: Arc<MemRefresh>,
|
||||||
|
pub audit: Arc<MemAudit>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
175
backend/crates/application/src/tests/auth_service_tests.rs
Normal file
175
backend/crates/application/src/tests/auth_service_tests.rs
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
2
backend/crates/application/src/tests/mod.rs
Normal file
2
backend/crates/application/src/tests/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
mod auth_service_tests;
|
||||||
|
mod user_service_tests;
|
||||||
170
backend/crates/application/src/tests/user_service_tests.rs
Normal file
170
backend/crates/application/src/tests/user_service_tests.rs
Normal file
@ -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<_>>(),
|
||||||
|
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);
|
||||||
|
}
|
||||||
46
backend/crates/application/src/user_service.rs
Normal file
46
backend/crates/application/src/user_service.rs
Normal file
@ -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<dyn UserRepository>,
|
||||||
|
pub(crate) hasher: Arc<dyn PasswordHasher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserService {
|
||||||
|
pub fn new(users: Arc<dyn UserRepository>, hasher: Arc<dyn PasswordHasher>) -> Self {
|
||||||
|
Self { users, hasher }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self) -> Result<Vec<User>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, _id: Uuid) -> Result<User, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self, _new: NewUser) -> Result<User, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(&self, _id: Uuid, _update: UserUpdate) -> Result<User, DomainError> {
|
||||||
|
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<bool, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/crates/domain/src/auth.rs
Normal file
67
backend/crates/domain/src/auth.rs
Normal file
@ -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<Utc>,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RefreshToken {
|
||||||
|
pub fn is_valid(&self, now: DateTime<Utc>) -> 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<Uuid>,
|
||||||
|
pub email: String,
|
||||||
|
pub kind: AuthEventKind,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub at: DateTime<Utc>,
|
||||||
|
}
|
||||||
21
backend/crates/domain/src/error.rs
Normal file
21
backend/crates/domain/src/error.rs
Normal file
@ -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),
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
42
backend/crates/domain/src/ports.rs
Normal file
42
backend/crates/domain/src/ports.rs
Normal file
@ -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<Option<User>, DomainError>;
|
||||||
|
async fn find_by_email(&self, email: &str) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn list(&self) -> Result<Vec<User>, DomainError>;
|
||||||
|
async fn count(&self) -> Result<u64, DomainError>;
|
||||||
|
async fn count_active_admins(&self) -> Result<u64, DomainError>;
|
||||||
|
async fn insert(&self, user: &User) -> Result<(), DomainError>;
|
||||||
|
async fn update(&self, id: Uuid, update: &UserUpdate) -> Result<User, DomainError>;
|
||||||
|
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<Option<RefreshToken>, 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<String, DomainError>;
|
||||||
|
fn verify(&self, password: &str, hash: &str) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait AccessTokenIssuer: Send + Sync {
|
||||||
|
fn issue(&self, user: &User) -> Result<String, DomainError>;
|
||||||
|
fn verify(&self, token: &str) -> Result<AccessClaims, DomainError>;
|
||||||
|
}
|
||||||
82
backend/crates/domain/src/user.rs
Normal file
82
backend/crates/domain/src/user.rs
Normal file
@ -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<Role> {
|
||||||
|
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<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
pub role: Option<Role>,
|
||||||
|
pub is_active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
54
frontend/e2e/auth.spec.ts
Normal file
54
frontend/e2e/auth.spec.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
test('app loads and shows backend health', async ({ page }) => {
|
test('login page loads', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/login')
|
||||||
await expect(page.getByText('SoftVisor Monitoring')).toBeVisible()
|
await expect(page.getByText('SoftVisor Monitoring')).toBeVisible()
|
||||||
await expect(page.getByTestId('backend-status')).toContainText('ok')
|
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
42
frontend/src/components/UserForm.test.ts
Normal file
42
frontend/src/components/UserForm.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import StatusBadge from '../components/StatusBadge.vue'
|
|
||||||
|
|
||||||
const status = ref<'ok' | 'error' | 'unknown'>('unknown')
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/healthz')
|
|
||||||
status.value = res.ok ? 'ok' : 'error'
|
|
||||||
} catch {
|
|
||||||
status.value = 'error'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main class="mx-auto max-w-2xl p-8">
|
|
||||||
<h1 class="text-2xl font-semibold">SoftVisor Monitoring</h1>
|
|
||||||
<p class="mt-4 text-gray-600">
|
|
||||||
Backend:
|
|
||||||
<span data-testid="backend-status"><StatusBadge :status="status" :label="status" /></span>
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
38
frontend/src/pages/LoginPage.test.ts
Normal file
38
frontend/src/pages/LoginPage.test.ts
Normal file
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
frontend/src/stores/auth.test.ts
Normal file
74
frontend/src/stores/auth.test.ts
Normal file
@ -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<unknown[]>('/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<string, string>)['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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user