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