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

View File

@ -0,0 +1,2 @@
mod auth_service_tests;
mod user_service_tests;

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