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:
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"
|
||||
))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user