application: auth bounded context (register, login)

This commit is contained in:
2026-07-12 01:49:34 +02:00
parent 848d4752e2
commit 2976600d12
14 changed files with 480 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
/// Register a new local user.
pub struct RegisterCommand {
pub email: String,
pub password: String,
}
/// Log in with email + password.
pub struct LoginCommand {
pub email: String,
pub password: String,
}

View File

@@ -0,0 +1,14 @@
use std::sync::Arc;
use domain::ports::{AuthService, EventPublisher, UserCommand, UserQuery};
/// Dependencies for auth use cases.
///
/// Aggregates the ports required by register/login operations.
/// Built once at startup and shared via `Arc<AuthDeps>` or passed by reference.
pub struct AuthDeps {
pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>,
pub auth_service: Arc<dyn AuthService>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -0,0 +1,38 @@
use domain::models::User;
use domain::{DomainError, DomainResult, Email};
use super::commands::LoginCommand;
use super::deps::AuthDeps;
/// Log in with email + password.
///
/// Flow: validate email -> find user -> verify password -> return User.
/// JWT generation belongs in the presentation layer, not here.
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<User> {
// Validate email format
let email = Email::new(&cmd.email)?;
// Find user
let user = deps
.user_query
.find_by_email(email.as_ref())
.await?
.ok_or_else(|| DomainError::unauthenticated("Invalid credentials"))?;
// Must have a password hash (not an OIDC-only user)
let hash = user
.password_hash()
.ok_or_else(|| DomainError::unauthenticated("Invalid credentials"))?;
// Verify password
let valid = deps.auth_service.verify_password(&cmd.password, hash)?;
if !valid {
return Err(DomainError::unauthenticated("Invalid credentials"));
}
Ok(user)
}
#[cfg(test)]
#[path = "tests/login.rs"]
mod tests;

View File

@@ -0,0 +1,8 @@
pub mod commands;
pub mod deps;
pub mod login;
pub mod queries;
pub mod register;
pub use commands::{LoginCommand, RegisterCommand};
pub use deps::AuthDeps;

View File

@@ -0,0 +1 @@
// Auth queries (reserved for future use, e.g. GetCurrentUserQuery).

View File

@@ -0,0 +1,46 @@
use domain::events::DomainEvent;
use domain::models::User;
use domain::{DomainResult, Email, Password};
use super::commands::RegisterCommand;
use super::deps::AuthDeps;
/// Register a new local user.
///
/// Flow: validate email/password -> check duplicate -> hash password ->
/// create User (first user gets admin) -> save -> publish event -> return User.
pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult<User> {
// Validate inputs via domain value objects
let email = Email::new(&cmd.email)?;
let password = Password::new(&cmd.password)?;
// Check for duplicate
if deps.user_query.find_by_email(email.as_ref()).await?.is_some() {
return Err(domain::DomainError::UserAlreadyExists(cmd.email));
}
// Hash password
let hash = deps.auth_service.hash_password(password.as_ref())?;
// Create user; first user gets admin
let mut user = User::new_local(email, hash);
if deps.user_query.count_users().await? == 0 {
user.promote_to_admin();
}
// Persist
deps.user_command.save(&user).await?;
// Publish event
deps.event_publisher
.publish(DomainEvent::UserRegistered {
user_id: user.id(),
})
.await?;
Ok(user)
}
#[cfg(test)]
#[path = "tests/register.rs"]
mod tests;

View File

@@ -0,0 +1,164 @@
use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use domain::{DomainError, Email};
use crate::auth::commands::LoginCommand;
use crate::auth::deps::AuthDeps;
use crate::auth::login;
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
struct FakeAuthService;
impl AuthService for FakeAuthService {
fn hash_password(&self, password: &str) -> DomainResult<String> {
Ok(format!("hashed:{}", password))
}
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
Ok(hash == format!("hashed:{}", password))
}
}
fn make_deps_with_user(
email: &str,
password_hash: &str,
) -> (AuthDeps, Arc<InMemoryUserRepository>) {
let repo = Arc::new(InMemoryUserRepository::new());
// Seed a user
let e = Email::new(email).unwrap();
let user = domain::models::User::new_local(e, password_hash);
repo.store
.lock()
.unwrap()
.insert(user.id().value(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn login_succeeds_with_correct_credentials() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let user = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert_eq!(user.email().as_ref(), "alice@example.com");
}
#[tokio::test]
async fn login_fails_with_wrong_password() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:correct");
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "wrong".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::Unauthenticated(_)
));
}
#[tokio::test]
async fn login_fails_for_unknown_email() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:pw");
let result = login::execute(
&deps,
LoginCommand {
email: "nobody@example.com".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::Unauthenticated(_)
));
}
#[tokio::test]
async fn login_fails_for_oidc_only_user() {
let repo = Arc::new(InMemoryUserRepository::new());
// Create an OIDC user (no password hash)
let email = Email::new("oidc@example.com").unwrap();
let user = domain::models::User::new("oidc|subject", email);
repo.store
.lock()
.unwrap()
.insert(user.id().value(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let result = login::execute(
&deps,
LoginCommand {
email: "oidc@example.com".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::Unauthenticated(_)
));
}
#[tokio::test]
async fn login_rejects_invalid_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let result = login::execute(
&deps,
LoginCommand {
email: "not-an-email".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ValidationError(_)
));
}

View File

@@ -0,0 +1,164 @@
use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use domain::{DomainError, Email};
use crate::auth::commands::RegisterCommand;
use crate::auth::deps::AuthDeps;
use crate::auth::register;
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
struct FakeAuthService;
impl AuthService for FakeAuthService {
fn hash_password(&self, password: &str) -> DomainResult<String> {
Ok(format!("hashed:{}", password))
}
fn verify_password(&self, password: &str, hash: &str) -> DomainResult<bool> {
Ok(hash == format!("hashed:{}", password))
}
}
fn make_deps() -> (AuthDeps, Arc<InMemoryUserRepository>) {
let repo = Arc::new(InMemoryUserRepository::new());
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
}
#[tokio::test]
async fn registers_new_user() {
let (deps, repo) = make_deps();
let user = register::execute(
&deps,
RegisterCommand {
email: "alice@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert_eq!(user.email().as_ref(), "alice@example.com");
assert!(user.password_hash().unwrap().starts_with("hashed:"));
// First user gets admin
assert!(user.is_admin());
// Verify persisted
let stored = repo
.store
.lock()
.unwrap()
.values()
.next()
.cloned()
.unwrap();
assert_eq!(stored.id(), user.id());
}
#[tokio::test]
async fn second_user_is_not_admin() {
let (deps, _) = make_deps();
// First user
register::execute(
&deps,
RegisterCommand {
email: "first@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
// Second user
let user = register::execute(
&deps,
RegisterCommand {
email: "second@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert!(!user.is_admin());
}
#[tokio::test]
async fn register_fails_for_duplicate_email() {
let (deps, repo) = make_deps();
// Pre-populate with existing user
let email = Email::new("taken@example.com").unwrap();
let existing = domain::models::User::new_local(email, "existing_hash");
repo.store
.lock()
.unwrap()
.insert(existing.id().value(), existing);
let result = register::execute(
&deps,
RegisterCommand {
email: "taken@example.com".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
DomainError::UserAlreadyExists(email) => {
assert_eq!(email, "taken@example.com");
}
other => panic!("expected UserAlreadyExists, got: {:?}", other),
}
}
#[tokio::test]
async fn register_rejects_invalid_email() {
let (deps, _) = make_deps();
let result = register::execute(
&deps,
RegisterCommand {
email: "not-an-email".into(),
password: "password123".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ValidationError(_)
));
}
#[tokio::test]
async fn register_rejects_short_password() {
let (deps, _) = make_deps();
let result = register::execute(
&deps,
RegisterCommand {
email: "valid@example.com".into(),
password: "short".into(),
},
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
DomainError::ValidationError(_)
));
}

View File

@@ -0,0 +1 @@
pub mod auth;