feat: enhance application state management with cookie support
- Added cookie key to AppState for managing session cookies. - Updated AppState initialization to derive cookie key from configuration. - Removed session-based authentication option from cargo-generate prompts. - Refactored JWT authentication logic to improve clarity and error handling. - Updated password validation to align with NIST recommendations (minimum length increased). - Removed unused session store implementation and related code. - Improved error handling in user repository for unique constraint violations. - Refactored OIDC service to include state management for authentication flow. - Cleaned up dependencies in Cargo.toml and Cargo.toml.template for clarity and efficiency.
This commit is contained in:
@@ -5,28 +5,16 @@ edition = "2024"
|
||||
|
||||
[features]
|
||||
default = ["sqlite"]
|
||||
sqlite = [
|
||||
"sqlx/sqlite",
|
||||
"k-core/sqlite",
|
||||
"tower-sessions-sqlx-store",
|
||||
"k-core/sessions-db",
|
||||
]
|
||||
postgres = [
|
||||
"sqlx/postgres",
|
||||
"k-core/postgres",
|
||||
"tower-sessions-sqlx-store",
|
||||
"k-core/sessions-db",
|
||||
]
|
||||
sqlite = ["sqlx/sqlite", "k-core/sqlite"]
|
||||
postgres = ["sqlx/postgres", "k-core/postgres"]
|
||||
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
|
||||
auth-axum-login = ["dep:axum-login", "dep:password-auth"]
|
||||
auth-oidc = ["dep:openidconnect", "dep:url"]
|
||||
auth-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"]
|
||||
auth-jwt = ["dep:jsonwebtoken"]
|
||||
|
||||
[dependencies]
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
"db-sqlx",
|
||||
"sessions-db",
|
||||
] }
|
||||
domain = { path = "../domain" }
|
||||
|
||||
@@ -38,19 +26,17 @@ anyhow = "1.0"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||
tower-sessions-sqlx-store = { version = "0.15.0", optional = true }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
futures-core = "0.3"
|
||||
password-auth = "1.0"
|
||||
|
||||
# Optional dependencies
|
||||
async-nats = { version = "0.45", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
futures-core = "0.3"
|
||||
tower-sessions = "0.14"
|
||||
|
||||
# Auth dependencies (optional)
|
||||
axum-login = { version = "0.18", optional = true }
|
||||
password-auth = { version = "1.0", optional = true }
|
||||
openidconnect = { version = "4.0.1", optional = true }
|
||||
url = { version = "2.5.8", optional = true }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private"], optional = true }
|
||||
jsonwebtoken = { version = "10.2.0", features = [
|
||||
"sha2",
|
||||
"p256",
|
||||
|
||||
@@ -4,29 +4,17 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = ["{{database}}"{% if auth_session %}, "auth-axum-login"{% endif %}{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
|
||||
sqlite = [
|
||||
"sqlx/sqlite",
|
||||
"k-core/sqlite",
|
||||
"tower-sessions-sqlx-store",
|
||||
"k-core/sessions-db",
|
||||
]
|
||||
postgres = [
|
||||
"sqlx/postgres",
|
||||
"k-core/postgres",
|
||||
"tower-sessions-sqlx-store",
|
||||
"k-core/sessions-db",
|
||||
]
|
||||
default = ["{{database}}"{% if auth_oidc %}, "auth-oidc"{% endif %}{% if auth_jwt %}, "auth-jwt"{% endif %}]
|
||||
sqlite = ["sqlx/sqlite", "k-core/sqlite"]
|
||||
postgres = ["sqlx/postgres", "k-core/postgres"]
|
||||
broker-nats = ["dep:futures-util", "k-core/broker-nats"]
|
||||
auth-axum-login = ["dep:axum-login", "dep:password-auth"]
|
||||
auth-oidc = ["dep:openidconnect", "dep:url"]
|
||||
auth-oidc = ["dep:openidconnect", "dep:url", "dep:axum-extra"]
|
||||
auth-jwt = ["dep:jsonwebtoken"]
|
||||
|
||||
[dependencies]
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
"db-sqlx",
|
||||
"sessions-db",
|
||||
] }
|
||||
domain = { path = "../domain" }
|
||||
|
||||
@@ -38,18 +26,15 @@ anyhow = "1.0"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||
tower-sessions-sqlx-store = { version = "0.15.0", optional = true }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
futures-core = "0.3"
|
||||
password-auth = "1.0"
|
||||
|
||||
# Optional dependencies
|
||||
async-nats = { version = "0.45", optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
futures-core = "0.3"
|
||||
tower-sessions = "0.14"
|
||||
|
||||
# Auth dependencies (optional)
|
||||
axum-login = { version = "0.18", optional = true }
|
||||
password-auth = { version = "1.0", optional = true }
|
||||
openidconnect = { version = "4.0.1", optional = true }
|
||||
url = { version = "2.5.8", optional = true }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private"], optional = true }
|
||||
jsonwebtoken = { version = "9.3", optional = true }
|
||||
# reqwest = { version = "0.13.1", features = ["blocking", "json"], optional = true }
|
||||
|
||||
@@ -2,122 +2,14 @@
|
||||
//!
|
||||
//! This module contains the concrete implementation of authentication mechanisms.
|
||||
|
||||
#[cfg(feature = "auth-axum-login")]
|
||||
pub mod backend {
|
||||
use std::sync::Arc;
|
||||
/// Hash a password using the password-auth crate
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
password_auth::generate_hash(password)
|
||||
}
|
||||
|
||||
use axum_login::{AuthnBackend, UserId};
|
||||
use password_auth::verify_password;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::SessionManagerLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{User, UserRepository};
|
||||
|
||||
// We use the same session store as defined in infra
|
||||
use crate::session_store::InfraSessionStore;
|
||||
|
||||
/// Wrapper around domain User to implement AuthUser
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthUser(pub User);
|
||||
|
||||
impl axum_login::AuthUser for AuthUser {
|
||||
type Id = Uuid;
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.0.id
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
// Use password hash to invalidate sessions if password changes
|
||||
self.0
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.map(|s| s.as_bytes())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthBackend {
|
||||
pub user_repo: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl AuthBackend {
|
||||
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Credentials {
|
||||
pub email: domain::Email,
|
||||
pub password: domain::Password,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error(transparent)]
|
||||
Anyhow(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl AuthnBackend for AuthBackend {
|
||||
type User = AuthUser;
|
||||
type Credentials = Credentials;
|
||||
type Error = AuthError;
|
||||
|
||||
async fn authenticate(
|
||||
&self,
|
||||
creds: Self::Credentials,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.find_by_email(creds.email.as_ref())
|
||||
.await
|
||||
.map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?;
|
||||
|
||||
if let Some(user) = user {
|
||||
if let Some(hash) = &user.password_hash {
|
||||
// Verify password
|
||||
if verify_password(creds.password.as_ref(), hash).is_ok() {
|
||||
return Ok(Some(AuthUser(user)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_user(
|
||||
&self,
|
||||
user_id: &UserId<Self>,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.find_by_id(*user_id)
|
||||
.await
|
||||
.map_err(|e| AuthError::Anyhow(anyhow::anyhow!(e)))?;
|
||||
|
||||
Ok(user.map(AuthUser))
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
|
||||
pub type AuthManagerLayer = axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>;
|
||||
|
||||
pub async fn setup_auth_layer(
|
||||
session_layer: SessionManagerLayer<InfraSessionStore>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
) -> Result<AuthManagerLayer, AuthError> {
|
||||
let backend = AuthBackend::new(user_repo);
|
||||
|
||||
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
|
||||
Ok(auth_layer)
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
password_auth::generate_hash(password)
|
||||
}
|
||||
/// Verify a password against a stored hash
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
password_auth::verify_password(password, hash).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
|
||||
@@ -15,6 +15,7 @@ use openidconnect::{
|
||||
},
|
||||
reqwest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub type OidcClient = Client<
|
||||
EmptyAdditionalClaims,
|
||||
@@ -36,9 +37,18 @@ pub type OidcClient = Client<
|
||||
EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing)
|
||||
>;
|
||||
|
||||
/// Serializable OIDC state stored in an encrypted cookie during the auth code flow
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct OidcState {
|
||||
pub csrf_token: CsrfToken,
|
||||
pub nonce: OidcNonce,
|
||||
pub pkce_verifier: PkceVerifier,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OidcService {
|
||||
client: OidcClient,
|
||||
http_client: reqwest::Client,
|
||||
resource_id: Option<ResourceId>,
|
||||
}
|
||||
|
||||
@@ -61,11 +71,7 @@ impl OidcService {
|
||||
tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url);
|
||||
tracing::debug!(
|
||||
"🔵 OIDC Setup: Secret = {:?}",
|
||||
if client_secret.is_some() {
|
||||
"SET"
|
||||
} else {
|
||||
"NONE"
|
||||
}
|
||||
if client_secret.is_some() { "SET" } else { "NONE" }
|
||||
);
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
@@ -78,13 +84,13 @@ impl OidcService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Convert to openidconnect types
|
||||
let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string());
|
||||
let oidc_client_secret = client_secret
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string()));
|
||||
let oidc_redirect_url = openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?;
|
||||
let oidc_redirect_url =
|
||||
openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
@@ -95,14 +101,16 @@ impl OidcService {
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
http_client,
|
||||
resource_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the authorization URL and associated state for OIDC login
|
||||
/// Get the authorization URL and associated state for OIDC login.
|
||||
///
|
||||
/// Returns structured data instead of a raw tuple for better type safety
|
||||
pub fn get_authorization_url(&self) -> AuthorizationUrlData {
|
||||
/// Returns `(AuthorizationUrlData, OidcState)` where `OidcState` should be
|
||||
/// serialized and stored in an encrypted cookie for the duration of the flow.
|
||||
pub fn get_authorization_url(&self) -> (AuthorizationUrlData, OidcState) {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (auth_url, csrf_token, nonce) = self
|
||||
@@ -117,12 +125,20 @@ impl OidcService {
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
AuthorizationUrlData {
|
||||
url: auth_url.into(),
|
||||
let oidc_state = OidcState {
|
||||
csrf_token: CsrfToken::new(csrf_token.secret().to_string()),
|
||||
nonce: OidcNonce::new(nonce.secret().to_string()),
|
||||
pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let auth_data = AuthorizationUrlData {
|
||||
url: auth_url.into(),
|
||||
csrf_token: oidc_state.csrf_token.clone(),
|
||||
nonce: oidc_state.nonce.clone(),
|
||||
pkce_verifier: oidc_state.pkce_verifier.clone(),
|
||||
};
|
||||
|
||||
(auth_data, oidc_state)
|
||||
}
|
||||
|
||||
/// Resolve the OIDC callback with type-safe parameters
|
||||
@@ -132,10 +148,6 @@ impl OidcService {
|
||||
nonce: OidcNonce,
|
||||
pkce_verifier: PkceVerifier,
|
||||
) -> anyhow::Result<OidcUser> {
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
|
||||
let oidc_pkce_verifier =
|
||||
openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string());
|
||||
let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string());
|
||||
@@ -146,7 +158,7 @@ impl OidcService {
|
||||
code.as_ref().to_string(),
|
||||
))?
|
||||
.set_pkce_verifier(oidc_pkce_verifier)
|
||||
.request_async(&http_client)
|
||||
.request_async(&self.http_client)
|
||||
.await?;
|
||||
|
||||
let id_token = token_response
|
||||
@@ -178,19 +190,17 @@ impl OidcService {
|
||||
let email = if let Some(email) = claims.email() {
|
||||
Some(email.as_str().to_string())
|
||||
} else {
|
||||
// Fallback: Call UserInfo Endpoint using the Access Token
|
||||
tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo...");
|
||||
|
||||
let user_info: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
|
||||
.client
|
||||
.user_info(token_response.access_token().clone(), None)?
|
||||
.request_async(&http_client)
|
||||
.request_async(&self.http_client)
|
||||
.await?;
|
||||
|
||||
user_info.email().map(|e| e.as_str().to_string())
|
||||
};
|
||||
|
||||
// If email is still missing, we must error out because your app requires valid emails
|
||||
let email =
|
||||
email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?;
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ use crate::SqliteUserRepository;
|
||||
use crate::db::DatabasePool;
|
||||
use domain::UserRepository;
|
||||
|
||||
use k_core::session::store::InfraSessionStore;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FactoryError {
|
||||
#[error("Database error: {0}")]
|
||||
@@ -33,18 +31,3 @@ pub async fn build_user_repository(pool: &DatabasePool) -> FactoryResult<Arc<dyn
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_session_store(
|
||||
pool: &DatabasePool,
|
||||
) -> FactoryResult<crate::session_store::InfraSessionStore> {
|
||||
Ok(match pool {
|
||||
#[cfg(feature = "sqlite")]
|
||||
DatabasePool::Sqlite(p) => {
|
||||
InfraSessionStore::Sqlite(tower_sessions_sqlx_store::SqliteStore::new(p.clone()))
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
DatabasePool::Postgres(p) => {
|
||||
InfraSessionStore::Postgres(tower_sessions_sqlx_store::PostgresStore::new(p.clone()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
//!
|
||||
//! ## Adapters
|
||||
//!
|
||||
//! - [`SqliteNoteRepository`] - SQLite adapter for notes with FTS5 search
|
||||
//! - [`SqliteUserRepository`] - SQLite adapter for users (OIDC-ready)
|
||||
//! - [`SqliteTagRepository`] - SQLite adapter for tags
|
||||
//!
|
||||
//! ## Database
|
||||
//!
|
||||
@@ -17,7 +15,6 @@
|
||||
pub mod auth;
|
||||
pub mod db;
|
||||
pub mod factory;
|
||||
pub mod session_store;
|
||||
mod user_repository;
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
pub use k_core::session::store::InfraSessionStore;
|
||||
pub use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
@@ -1,27 +1,13 @@
|
||||
//! SQLite implementation of UserRepository
|
||||
//! SQLite and PostgreSQL implementations of UserRepository
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{FromRow, SqlitePool};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{DomainError, DomainResult, Email, User, UserRepository};
|
||||
|
||||
/// SQLite adapter for UserRepository
|
||||
#[cfg(feature = "sqlite")]
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteUserRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
impl SqliteUserRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Row type for SQLite query results
|
||||
/// Row type for database query results (shared between SQLite and PostgreSQL)
|
||||
#[derive(Debug, FromRow)]
|
||||
struct UserRow {
|
||||
id: String,
|
||||
@@ -46,7 +32,6 @@ impl TryFrom<UserRow> for User {
|
||||
})
|
||||
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
|
||||
|
||||
// Parse email from string - it was validated when originally stored
|
||||
let email = Email::try_from(row.email)
|
||||
.map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?;
|
||||
|
||||
@@ -60,6 +45,20 @@ impl TryFrom<UserRow> for User {
|
||||
}
|
||||
}
|
||||
|
||||
/// SQLite adapter for UserRepository
|
||||
#[cfg(feature = "sqlite")]
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteUserRepository {
|
||||
pool: sqlx::SqlitePool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
impl SqliteUserRepository {
|
||||
pub fn new(pool: sqlx::SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
#[async_trait]
|
||||
impl UserRepository for SqliteUserRepository {
|
||||
@@ -116,12 +115,20 @@ impl UserRepository for SqliteUserRepository {
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user.subject)
|
||||
.bind(user.email.as_ref()) // Use .as_ref() to get the inner &str
|
||||
.bind(user.email.as_ref())
|
||||
.bind(&user.password_hash)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
// Surface UNIQUE constraint violations as domain-level conflicts
|
||||
let msg = e.to_string();
|
||||
if msg.contains("UNIQUE constraint failed") || msg.contains("unique constraint") {
|
||||
DomainError::UserAlreadyExists(user.email.as_ref().to_string())
|
||||
} else {
|
||||
DomainError::RepositoryError(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -144,7 +151,7 @@ mod tests {
|
||||
use crate::db::run_migrations;
|
||||
use k_core::db::{DatabaseConfig, DatabasePool, connect};
|
||||
|
||||
async fn setup_test_db() -> SqlitePool {
|
||||
async fn setup_test_db() -> sqlx::SqlitePool {
|
||||
let config = DatabaseConfig::default();
|
||||
let db_pool = connect(&config).await.expect("Failed to create pool");
|
||||
|
||||
@@ -168,7 +175,7 @@ mod tests {
|
||||
assert!(found.is_some());
|
||||
let found = found.unwrap();
|
||||
assert_eq!(found.subject, "oidc|123");
|
||||
assert_eq!(found.email_str(), "test@example.com");
|
||||
assert_eq!(found.email.as_ref(), "test@example.com");
|
||||
assert!(found.password_hash.is_none());
|
||||
}
|
||||
|
||||
@@ -184,7 +191,7 @@ mod tests {
|
||||
let found = repo.find_by_id(user.id).await.unwrap();
|
||||
assert!(found.is_some());
|
||||
let found = found.unwrap();
|
||||
assert_eq!(found.email_str(), "local@example.com");
|
||||
assert_eq!(found.email.as_ref(), "local@example.com");
|
||||
assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
|
||||
}
|
||||
|
||||
@@ -292,7 +299,14 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("unique constraint") || msg.contains("duplicate key") {
|
||||
DomainError::UserAlreadyExists(user.email.as_ref().to_string())
|
||||
} else {
|
||||
DomainError::RepositoryError(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user