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

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

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

View File

@ -0,0 +1,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,
}
}