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