- Moved schedule repository logic into separate modules for SQLite and PostgreSQL implementations. - Created a mapping module for shared data structures and mapping functions in the schedule repository. - Added new mapping module for user repository to handle user data transformations. - Implemented PostgreSQL and SQLite user repository adapters with necessary CRUD operations. - Added tests for user repository functionality, including saving, finding, and deleting users.
228 lines
6.2 KiB
Rust
228 lines
6.2 KiB
Rust
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|
use std::fmt;
|
|
use thiserror::Error;
|
|
|
|
// ============================================================================
|
|
// Validation Error
|
|
// ============================================================================
|
|
|
|
/// Errors that occur when parsing/validating value objects
|
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
|
#[non_exhaustive]
|
|
pub enum ValidationError {
|
|
#[error("Invalid email format: {0}")]
|
|
InvalidEmail(String),
|
|
|
|
#[error("Password must be at least {min} characters, got {actual}")]
|
|
PasswordTooShort { min: usize, actual: usize },
|
|
|
|
#[error("Invalid URL: {0}")]
|
|
InvalidUrl(String),
|
|
|
|
#[error("Value cannot be empty: {0}")]
|
|
Empty(String),
|
|
|
|
#[error("Secret too short: minimum {min} bytes required, got {actual}")]
|
|
SecretTooShort { min: usize, actual: usize },
|
|
}
|
|
|
|
// ============================================================================
|
|
// Email (using email_address crate for RFC-compliant validation)
|
|
// ============================================================================
|
|
|
|
/// A validated email address using RFC-compliant validation.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct Email(email_address::EmailAddress);
|
|
|
|
impl Email {
|
|
/// Create a new validated email address
|
|
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
|
let value = value.as_ref().trim().to_lowercase();
|
|
let addr: email_address::EmailAddress = value
|
|
.parse()
|
|
.map_err(|_| ValidationError::InvalidEmail(value.clone()))?;
|
|
Ok(Self(addr))
|
|
}
|
|
|
|
/// Get the inner value
|
|
pub fn into_inner(self) -> String {
|
|
self.0.to_string()
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for Email {
|
|
fn as_ref(&self) -> &str {
|
|
self.0.as_ref()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Email {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for Email {
|
|
type Error = ValidationError;
|
|
|
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
Self::new(value)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for Email {
|
|
type Error = ValidationError;
|
|
|
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
|
Self::new(value)
|
|
}
|
|
}
|
|
|
|
impl Serialize for Email {
|
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
serializer.serialize_str(self.0.as_ref())
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for Email {
|
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
let s = String::deserialize(deserializer)?;
|
|
Self::new(s).map_err(serde::de::Error::custom)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Password
|
|
// ============================================================================
|
|
|
|
/// A validated password input (NOT the hash).
|
|
///
|
|
/// Enforces minimum length of 6 characters.
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct Password(String);
|
|
|
|
/// Minimum password length (NIST recommendation)
|
|
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
|
|
|
impl Password {
|
|
pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
|
|
let value = value.into();
|
|
|
|
if value.len() < MIN_PASSWORD_LENGTH {
|
|
return Err(ValidationError::PasswordTooShort {
|
|
min: MIN_PASSWORD_LENGTH,
|
|
actual: value.len(),
|
|
});
|
|
}
|
|
|
|
Ok(Self(value))
|
|
}
|
|
|
|
pub fn into_inner(self) -> String {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for Password {
|
|
fn as_ref(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
// Intentionally hide password content in Debug
|
|
impl fmt::Debug for Password {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "Password(***)")
|
|
}
|
|
}
|
|
|
|
impl TryFrom<String> for Password {
|
|
type Error = ValidationError;
|
|
|
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
|
Self::new(value)
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for Password {
|
|
type Error = ValidationError;
|
|
|
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
|
Self::new(value)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for Password {
|
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
let s = String::deserialize(deserializer)?;
|
|
Self::new(s).map_err(serde::de::Error::custom)
|
|
}
|
|
}
|
|
|
|
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
mod email_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_valid_email() {
|
|
assert!(Email::new("user@example.com").is_ok());
|
|
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
|
|
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
|
|
}
|
|
|
|
#[test]
|
|
fn test_email_normalizes() {
|
|
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
|
assert_eq!(email.as_ref(), "user@example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_email_no_at() {
|
|
assert!(Email::new("userexample.com").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_email_no_domain() {
|
|
assert!(Email::new("user@").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_email_no_local() {
|
|
assert!(Email::new("@example.com").is_err());
|
|
}
|
|
}
|
|
|
|
mod password_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_valid_password() {
|
|
assert!(Password::new("secret123").is_ok());
|
|
assert!(Password::new("12345678").is_ok()); // Exactly 8 chars
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_too_short() {
|
|
assert!(Password::new("1234567").is_err()); // 7 chars
|
|
assert!(Password::new("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_debug_hides_content() {
|
|
let password = Password::new("supersecret").unwrap();
|
|
let debug = format!("{:?}", password);
|
|
assert!(!debug.contains("supersecret"));
|
|
assert!(debug.contains("***"));
|
|
}
|
|
}
|
|
}
|