diff --git a/Cargo.lock b/Cargo.lock index 9dd5c81..69d09f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,16 @@ dependencies = [ "libc", ] +[[package]] +name = "application" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "tokio", + "uuid", +] + [[package]] name = "async-trait" version = "0.1.89" diff --git a/Cargo.toml b/Cargo.toml index b6e2ad8..a0722fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/domain"] +members = ["crates/domain", "crates/application"] exclude = ["k-tv-backend", "k-tv-frontend"] resolver = "2" diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..949ea14 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } +tokio = { workspace = true } diff --git a/crates/application/src/auth/commands.rs b/crates/application/src/auth/commands.rs new file mode 100644 index 0000000..47f22bc --- /dev/null +++ b/crates/application/src/auth/commands.rs @@ -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, +} diff --git a/crates/application/src/auth/deps.rs b/crates/application/src/auth/deps.rs new file mode 100644 index 0000000..415d9c2 --- /dev/null +++ b/crates/application/src/auth/deps.rs @@ -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` or passed by reference. +pub struct AuthDeps { + pub user_command: Arc, + pub user_query: Arc, + pub auth_service: Arc, + pub event_publisher: Arc, +} diff --git a/crates/application/src/auth/login.rs b/crates/application/src/auth/login.rs new file mode 100644 index 0000000..20cc1d9 --- /dev/null +++ b/crates/application/src/auth/login.rs @@ -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 { + // 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; diff --git a/crates/application/src/auth/mod.rs b/crates/application/src/auth/mod.rs new file mode 100644 index 0000000..f96a2e7 --- /dev/null +++ b/crates/application/src/auth/mod.rs @@ -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; diff --git a/crates/application/src/auth/queries.rs b/crates/application/src/auth/queries.rs new file mode 100644 index 0000000..a7ecfdd --- /dev/null +++ b/crates/application/src/auth/queries.rs @@ -0,0 +1 @@ +// Auth queries (reserved for future use, e.g. GetCurrentUserQuery). diff --git a/crates/application/src/auth/register.rs b/crates/application/src/auth/register.rs new file mode 100644 index 0000000..15439d2 --- /dev/null +++ b/crates/application/src/auth/register.rs @@ -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 { + // 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; diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs new file mode 100644 index 0000000..967e4bc --- /dev/null +++ b/crates/application/src/auth/tests/login.rs @@ -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 { + Ok(format!("hashed:{}", password)) + } + + fn verify_password(&self, password: &str, hash: &str) -> DomainResult { + Ok(hash == format!("hashed:{}", password)) + } +} + +fn make_deps_with_user( + email: &str, + password_hash: &str, +) -> (AuthDeps, Arc) { + 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(_) + )); +} diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/src/auth/tests/register.rs new file mode 100644 index 0000000..69f27f9 --- /dev/null +++ b/crates/application/src/auth/tests/register.rs @@ -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 { + Ok(format!("hashed:{}", password)) + } + + fn verify_password(&self, password: &str, hash: &str) -> DomainResult { + Ok(hash == format!("hashed:{}", password)) + } +} + +fn make_deps() -> (AuthDeps, Arc) { + 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(_) + )); +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/crates/domain/src/events/mod.rs b/crates/domain/src/events/mod.rs index 3540faa..6224a24 100644 --- a/crates/domain/src/events/mod.rs +++ b/crates/domain/src/events/mod.rs @@ -32,6 +32,8 @@ pub enum DomainEvent { ChannelUpdated { channel_id: ChannelId }, /// A channel was deleted. ChannelDeleted { channel_id: ChannelId }, + /// A new user was registered. + UserRegistered { user_id: crate::value_objects::UserId }, } #[cfg(test)] diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs index dc63389..6df58e1 100644 --- a/crates/domain/src/models/user.rs +++ b/crates/domain/src/models/user.rs @@ -85,6 +85,13 @@ impl User { pub fn created_at(&self) -> DateTime { self.created_at } + + // -- Mutations -- + + /// Promote this user to admin. + pub fn promote_to_admin(&mut self) { + self.is_admin = true; + } } #[cfg(test)]