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,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>,
}

View 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),
}

View File

@ -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;

View 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>;
}

View 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"
))
})
}