user_service.rs
1077 lignes · 40337 octets
use crate::error::AppError; use crate::models::{role, user, user_role}; use crate::services::password_service::PasswordService; use crate::services::refresh_token_service::RefreshTokenService; use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, NotSet, QueryFilter, Set, }; use uuid::Uuid; /// Service for managing users /// /// This service provides business logic for user operations, /// including creating users and assigning roles. pub struct UserService; impl UserService { /// Create a new user with the given details. /// /// Enforces [`crate::types::Username::parse`] (strict): charset /// `[a-z0-9-]`, length 3–39, no consecutive `--`, no leading/trailing `-`, /// and reserved names rejected. This is the single validation gate for /// every caller. /// /// For the bootstrap path where `"admin"` (a reserved name) must be /// accepted, use the crate-private `create_user_allow_reserved`. /// /// # Arguments /// /// * `db` - Database connection /// * `username` - Desired username; validated by [`crate::types::Username::parse`] /// * `email` - Email address (must be unique) /// * `password_hash` - Pre-hashed password (use bcrypt) /// * `must_change_password` - Whether user must change password on next login pub async fn create_user( db: &DatabaseConnection, username: String, email: String, password_hash: String, must_change_password: bool, ) -> Result<user::Model, AppError> { let username = crate::types::Username::parse(&username)?.into_inner(); Self::do_insert_user(db, username, email, password_hash, must_change_password).await } /// Bootstrap-only path: create a user whose username may be reserved. /// /// Identical to [`UserService::create_user`] except it calls /// [`Username::parse_allow_reserved`], so names such as `"admin"` are /// accepted. All charset and length rules still apply. /// /// **Only `ensure_default_admin` should call this function.** Every /// other caller must use [`UserService::create_user`] which enforces /// the full reserved-name policy. pub(crate) async fn create_user_allow_reserved( db: &DatabaseConnection, username: String, email: String, password_hash: String, must_change_password: bool, ) -> Result<user::Model, AppError> { let username = crate::types::Username::parse_allow_reserved(&username)?.into_inner(); Self::do_insert_user(db, username, email, password_hash, must_change_password).await } /// Internal DB insert shared by [`create_user`] and /// [`create_user_allow_reserved`]. /// /// The caller is responsible for pre-validating `username` via the /// appropriate `Username::parse*` variant before calling this. async fn do_insert_user( db: &DatabaseConnection, username: String, email: String, password_hash: String, must_change_password: bool, ) -> Result<user::Model, AppError> { let now = Utc::now(); let user = user::ActiveModel { id: Set(Uuid::new_v4()), username: Set(username), email: Set(email), password_hash: Set(password_hash), must_change_password: Set(must_change_password), last_login_at: NotSet, last_login_ip: NotSet, password_changed_at: NotSet, email_verified: Set(false), email_verified_at: NotSet, created_at: Set(now), updated_at: Set(now), ap_public_key_pem: NotSet, ap_private_key_pem: NotSet, ap_discoverable: NotSet, }; let result = user.insert(db).await?; Ok(result) } /// Assign a role to a user pub async fn assign_role_to_user( db: &DatabaseConnection, user_id: Uuid, role_id: Uuid, ) -> Result<user_role::Model, AppError> { let now = Utc::now(); let user_role_assoc = user_role::ActiveModel { user_id: Set(user_id), role_id: Set(role_id), created_at: Set(now), }; let result = user_role_assoc.insert(db).await?; Ok(result) } /// Get a user by ID pub async fn get_user_by_id( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Option<user::Model>, AppError> { let user = user::Entity::find_by_id(user_id).one(db).await?; Ok(user) } /// Get a user by username pub async fn get_user_by_username( db: &DatabaseConnection, username: &str, ) -> Result<Option<user::Model>, AppError> { let user = user::Entity::find() .filter(user::Column::Username.eq(username)) .one(db) .await?; Ok(user) } /// Get a user by email pub async fn get_user_by_email( db: &DatabaseConnection, email: &str, ) -> Result<Option<user::Model>, AppError> { let user = user::Entity::find() .filter(user::Column::Email.eq(email)) .one(db) .await?; Ok(user) } /// List all users pub async fn list_all_users(db: &DatabaseConnection) -> Result<Vec<user::Model>, AppError> { let users = user::Entity::find().all(db).await?; Ok(users) } /// Get user roles by user ID /// /// Fetches all roles assigned to a user through the user_roles junction table. /// /// # Returns /// /// A vector of role names (e.g., ["admin"], ["user"]) pub async fn get_user_roles( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<String>, AppError> { let user_roles = user_role::Entity::find() .filter(user_role::Column::UserId.eq(user_id)) .all(db) .await?; let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect(); if role_ids.is_empty() { return Ok(vec![]); } let roles = role::Entity::find() .filter(role::Column::Id.is_in(role_ids)) .all(db) .await?; let role_names: Vec<String> = roles.iter().map(|r| r.name.clone()).collect(); Ok(role_names) } /// Update user profile /// /// Allows updating username and/or email with validation for uniqueness. /// /// # Email changes are refused here (RFC 0041) /// /// This method **never** writes `users.email`. Passing an address that /// differs from the stored one (exact comparison after trim) returns /// [`AppError::Validation`] with a message pointing at /// [`EmailChangeService`](crate::services::email_change_service::EmailChangeService): /// the address is only written once the change is confirmed from the link /// mailed to it, so a typo can no longer lock an account out. /// /// Echoing the **current** address stays a no-op — profile forms submit /// every field — and a username-only rename is unaffected. /// /// The operator recourse is /// [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change); /// there is no dérogation branch hidden in this method. /// /// ```rust,ignore /// use rustwarden_core::services::user_service::UserService; /// /// // A username-only rename is unaffected. /// let user = UserService::update_profile(&db, user_id, Some("newname".into()), None).await?; /// /// // Changing the email is refused: use EmailChangeService::request_change. /// let err = UserService::update_profile(&db, user_id, None, Some("new@example.com".into())) /// .await /// .unwrap_err(); /// ``` pub async fn update_profile( db: &DatabaseConnection, user_id: Uuid, username: Option<String>, email: Option<String>, ) -> Result<user::Model, AppError> { let user = Self::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; let mut updated_user: user::ActiveModel = user.clone().into(); let mut changes = false; if let Some(new_username) = &username { let trimmed = new_username.trim(); // Only enforce charset + reserved on an actual rename. // If the client echoes back the current username (including // grandfathered out-of-charset handles), treat it as a no-op. if trimmed != user.username.as_str() { let parsed = crate::types::Username::parse(trimmed)?; if let Some(existing_user) = Self::get_user_by_username(db, parsed.as_str()).await? { if existing_user.id != user_id { return Err(AppError::Validation(format!( "Username '{}' already exists", parsed.as_str() ))); } } updated_user.username = Set(parsed.into_inner()); changes = true; } // Same username: no-op — grandfathering preserved. } // RFC 0041: `update_profile` never writes `users.email`. if let Some(new_email) = &email { let trimmed = new_email.trim(); // Echoing the current address stays a no-op: profile forms submit // every field, so an unchanged value must not be treated as a // change request. if trimmed != user.email.as_str() { return Err(AppError::Validation( "Email changes are not handled by update_profile; call \ EmailChangeService::request_change (POST /api/v1/auth/email-change) \ — the address is only written once the change is confirmed from \ the link mailed to it." .to_string(), )); } } if !changes { return Ok(user); } updated_user.updated_at = Set(Utc::now()); let user = updated_user.update(db).await?; tracing::info!("Profile updated for user {}", user_id); Ok(user) } /// Change user password /// /// Validates old password, checks new password strength, and creates a pending /// password change request that requires email confirmation. /// /// # Returns /// /// Tuple of (request_id, plain_token) - the plain token must be sent via email pub async fn change_password( db: &DatabaseConnection, user_id: Uuid, old_password: &str, new_password: &str, _current_token_id: Option<Uuid>, ) -> Result<(Uuid, String), AppError> { use crate::services::password_change_service::PasswordChangeService; use crate::services::password_service::PasswordService; let user = Self::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; if !PasswordService::verify_password(old_password, &user.password_hash).await? { return Err(AppError::Validation( "Old password is incorrect".to_string(), )); } crate::middleware::password_complexity::validate_password_complexity(db, new_password) .await?; let password_change_service = PasswordChangeService::new(); let (request_id, token) = password_change_service .create_pending_request(db, user_id, new_password) .await?; tracing::info!( "Created pending password change request {} for user {}", request_id, user_id ); Ok((request_id, token)) } /// Get all admin users (users with admin role) pub async fn get_all_admin_users( db: &DatabaseConnection, ) -> Result<Vec<user::Model>, AppError> { use crate::services::role_service::RoleService; let admin_role = RoleService::get_role_by_name(db, "admin") .await? .ok_or_else(|| AppError::NotFound("Admin role not found".to_string()))?; let admin_user_roles = user_role::Entity::find() .filter(user_role::Column::RoleId.eq(admin_role.id)) .all(db) .await?; let admin_user_ids: Vec<Uuid> = admin_user_roles.iter().map(|ur| ur.user_id).collect(); if admin_user_ids.is_empty() { return Ok(vec![]); } let admin_users = user::Entity::find() .filter(user::Column::Id.is_in(admin_user_ids)) .all(db) .await?; Ok(admin_users) } /// Update user password hash directly (for password change confirmation) /// /// Used internally by PasswordChangeService to apply confirmed password changes. pub async fn update_password_hash( db: &DatabaseConnection, user_id: Uuid, password_hash: &str, ) -> Result<(), AppError> { let user = Self::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; let mut updated_user: user::ActiveModel = user.into(); updated_user.password_hash = Set(password_hash.to_string()); updated_user.must_change_password = Set(false); updated_user.password_changed_at = Set(Some(Utc::now())); updated_user.updated_at = Set(Utc::now()); updated_user.update(db).await?; Ok(()) } /// Remove a role from a user. /// /// Prevents removing the `admin` role from the last remaining admin /// to avoid locking out the application. /// /// # Errors /// /// * `AppError::Validation` if this would remove the last admin pub async fn unassign_role_from_user( db: &DatabaseConnection, user_id: Uuid, role_id: Uuid, ) -> Result<(), AppError> { // Protect last admin let role = role::Entity::find_by_id(role_id) .one(db) .await? .ok_or_else(|| AppError::NotFound("Role not found".to_string()))?; if role.name == "admin" { let admin_count = Self::get_all_admin_users(db).await?.len(); if admin_count <= 1 { return Err(AppError::Validation( "Cannot remove the admin role from the last administrator".to_string(), )); } } user_role::Entity::delete_many() .filter(user_role::Column::UserId.eq(user_id)) .filter(user_role::Column::RoleId.eq(role_id)) .exec(db) .await?; tracing::info!("Role '{}' removed from user {}", role.name, user_id); Ok(()) } /// Delete a user and clean up all related data. /// /// This method: /// 1. Calls the `on_user_deleted` hook (so consumers can clean up business data) /// 2. Revokes all refresh tokens for the user /// 3. Deletes the user (cascade handles user_roles, resources, shares, oauth_accounts) /// /// # Errors /// /// * `AppError::NotFound` if user does not exist /// * `AppError::Validation` if user is the last admin pub async fn delete_user( db: &DatabaseConnection, user_id: Uuid, hooks: Option<&dyn crate::hooks::RustwardenHooks>, ) -> Result<(), AppError> { let user = Self::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; // Protect last admin let roles = Self::get_user_roles(db, user_id).await?; if roles.contains(&"admin".to_string()) { let admin_count = Self::get_all_admin_users(db).await?.len(); if admin_count <= 1 { return Err(AppError::Validation( "Cannot delete the last administrator".to_string(), )); } } // Hook: let consumers clean up their business data before cascade if let Some(hooks) = hooks { if let Err(e) = hooks.on_user_deleted(db, user_id, &user.username).await { tracing::warn!("on_user_deleted hook error for user {}: {}", user_id, e); } } // Revoke all refresh tokens (invalidates active sessions) RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?; // Delete user (cascade handles user_roles, resources, shares, oauth_accounts) user.delete(db).await?; tracing::info!("User {} deleted", user_id); Ok(()) } /// Admin password reset: set a new password directly without email confirmation. /// /// Sets `must_change_password = true` so the user is forced to choose /// their own password at next login. Revokes all existing sessions (SEC-M7). /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - Target user ID /// * `new_password` - New plaintext password (will be hashed) pub async fn admin_reset_password( db: &DatabaseConnection, user_id: Uuid, new_password: &str, ) -> Result<(), AppError> { let hash = PasswordService::hash_password(new_password).await?; let user = Self::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; let mut active: user::ActiveModel = user.into(); active.password_hash = Set(hash); active.must_change_password = Set(true); active.password_changed_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); active.update(db).await?; // SEC-M7: invalidate all sessions RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?; tracing::info!("Admin reset password for user {}", user_id); Ok(()) } /// Change password with old password verification (for authenticated users). /// /// Simpler than `PasswordChangeService` (no email confirmation required). /// Validates password complexity, verifies old password, hashes new password, /// and revokes all sessions (SEC-M7). /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - Authenticated user ID /// * `old_password` - Current password for verification /// * `new_password` - New plaintext password pub async fn change_password_direct( db: &DatabaseConnection, user_id: Uuid, old_password: &str, new_password: &str, ) -> Result<(), AppError> { let user = Self::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; if !PasswordService::verify_password(old_password, &user.password_hash).await? { return Err(AppError::Validation( "Current password is incorrect".to_string(), )); } crate::middleware::password_complexity::validate_password_complexity(db, new_password) .await?; let hash = PasswordService::hash_password(new_password).await?; let mut active: user::ActiveModel = user.into(); active.password_hash = Set(hash); active.must_change_password = Set(false); active.password_changed_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); active.update(db).await?; // SEC-M7: invalidate all sessions RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?; tracing::info!("Password changed directly for user {}", user_id); Ok(()) } } #[cfg(test)] mod tests { use super::*; // UserService, AppError, Uuid, Utc, user, Set/NotSet, ModelTrait, … use crate::models::email_queue; async fn test_db() -> DatabaseConnection { dotenvy::dotenv().ok(); let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required for user_service tests"); sea_orm::Database::connect(&url).await.unwrap() } async fn dummy_hash() -> String { PasswordService::hash_password("TestPassword1!") .await .unwrap() } // ── create_user: invalid charset ──────────────────────────────────────── /// `create_user` must reject the username before any INSERT. #[tokio::test] async fn create_user_invalid_charset_returns_validation_error() { let db = test_db().await; let result = UserService::create_user( &db, "x`id`y".to_string(), "backtick-test@example.com".to_string(), "irrelevant-hash".to_string(), false, ) .await; assert!( matches!(result, Err(AppError::Validation(_))), "backtick username must be rejected before any DB insert" ); let loaded = UserService::get_user_by_username(&db, "x`id`y") .await .unwrap(); assert!( loaded.is_none(), "no row should exist after a rejected create_user" ); } // ── create_user: happy path ────────────────────────────────────────────── #[tokio::test] async fn create_user_valid_username_succeeds() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); let username = format!("alice-{}", &unique[..8]); let result = UserService::create_user( &db, username.clone(), format!("alice-{}@example.com", &unique[..8]), dummy_hash().await, false, ) .await; assert!( result.is_ok(), "valid username must succeed: {:?}", result.err() ); let created = result.unwrap(); assert_eq!(created.username, username); // Non-regression (RFC 0038): new accounts always start unverified. assert!( !created.email_verified, "create_user must set email_verified=false" ); assert!( created.email_verified_at.is_none(), "create_user must leave email_verified_at unset" ); } // ── create_user: reserved name rejected by strict gate ────────────────── #[tokio::test] async fn create_user_reserved_name_returns_validation_error() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); let result = UserService::create_user( &db, "admin".to_string(), format!("admin-strict-{}@example.com", &unique[..8]), "irrelevant-hash".to_string(), false, ) .await; assert!( matches!(result, Err(AppError::Validation(_))), "'admin' must be rejected by strict create_user" ); } // ── create_user_allow_reserved: bootstrap accepts reserved name ────────── #[tokio::test] async fn create_user_allow_reserved_accepts_admin() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); // If an "admin" user already exists (real deploy data or a previous // test run), skip rather than deleting it — tests must never destroy // data they did not create (FK cascade would wipe teams, PAT, etc.). if UserService::get_user_by_username(&db, "admin") .await .unwrap() .is_some() { return; } let result = UserService::create_user_allow_reserved( &db, "admin".to_string(), format!("admin-bootstrap-{}@example.com", &unique[..8]), dummy_hash().await, false, ) .await; assert!( result.is_ok(), "bootstrap path must accept reserved name 'admin': {:?}", result.err() ); let created = result.unwrap(); assert_eq!(created.username, "admin"); // Clean up only the row this test created (by the id we hold). created.delete(&db).await.unwrap(); } // ── grandfathering: pre-RFC accounts must remain usable ───────────────── #[tokio::test] async fn grandfather_load_and_profile_update_work() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); // Use an underscore username to simulate a pre-RFC (grandfathered) account. let legacy_username = format!("john_doe_{}", &unique[..8]); let legacy_id = Uuid::new_v4(); let now = Utc::now(); // Insert directly, bypassing Username::parse (simulates a pre-RFC row). user::ActiveModel { id: Set(legacy_id), username: Set(legacy_username.clone()), email: Set(format!("johndoe-{}@legacy.example.com", &unique[..8])), password_hash: Set("hashed".to_string()), must_change_password: Set(false), last_login_at: NotSet, last_login_ip: NotSet, password_changed_at: NotSet, email_verified: Set(false), email_verified_at: NotSet, created_at: Set(now), updated_at: Set(now), ap_public_key_pem: NotSet, ap_private_key_pem: NotSet, ap_discoverable: NotSet, } .insert(&db) .await .unwrap(); // Grandfathered account must still be loadable. let loaded = UserService::get_user_by_username(&db, &legacy_username) .await .unwrap(); assert!(loaded.is_some(), "grandfathered user must be loadable"); // RFC 0041: echoing the stored address is a no-op and must not touch // or re-validate the username. let legacy_email = format!("johndoe-{}@legacy.example.com", &unique[..8]); let updated = UserService::update_profile(&db, legacy_id, None, Some(legacy_email.clone())) .await .unwrap(); assert_eq!( updated.username, legacy_username, "username must be unchanged after an email echo" ); assert_eq!(updated.email, legacy_email); // Echoing the same out-of-charset username (form POST round-trip) is a no-op. let result = UserService::update_profile(&db, legacy_id, Some(legacy_username.clone()), None).await; assert!( result.is_ok(), "echoing same out-of-charset username must succeed (grandfathering no-op)" ); assert_eq!( result.unwrap().username, legacy_username, "username must still be unchanged after echo no-op" ); } // ── rename validation ──────────────────────────────────────────────────── #[tokio::test] async fn rename_invalid_or_reserved_is_rejected() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); let username = format!("renametest-{}", &unique[..8]); let email = format!("renametest-{}@example.com", &unique[..8]); let user_model = UserService::create_user( &db, username.clone(), email.clone(), dummy_hash().await, false, ) .await .unwrap(); // RFC 0038: mark verified so the username-only rename below can prove // it never touches the verification flags. let mut active: user::ActiveModel = user_model.clone().into(); active.email_verified = Set(true); active.email_verified_at = Set(Some(Utc::now())); active.update(&db).await.unwrap(); // Uppercase → Validation error. assert!( matches!( UserService::update_profile(&db, user_model.id, Some("Not-Valid".into()), None) .await, Err(AppError::Validation(_)) ), "uppercase username must be rejected on rename" ); // Reserved name → Validation error. assert!( matches!( UserService::update_profile(&db, user_model.id, Some("api".into()), None).await, Err(AppError::Validation(_)) ), "reserved name 'api' must be rejected on rename" ); // Valid rename → accepted. let new_name = format!("renamed-{}", &unique[..8]); let renamed = UserService::update_profile(&db, user_model.id, Some(new_name.clone()), None) .await .unwrap(); assert_eq!(renamed.username, new_name, "valid rename must succeed"); // RFC 0038: a username-only rename must leave the email and its // verification flags intact, and must not queue any email. assert_eq!(renamed.email, email, "email must be unchanged after rename"); assert!( renamed.email_verified, "username-only rename must not clear email_verified" ); assert!( renamed.email_verified_at.is_some(), "username-only rename must not clear email_verified_at" ); assert!( queue_entries_for(&db, &email).await.is_empty(), "username-only rename must not enqueue any email from the service" ); } // ── RFC 0041: `update_profile` never writes `users.email` ─────────────── /// Return every `email_queue` row addressed to `to_email`. async fn queue_entries_for(db: &DatabaseConnection, to_email: &str) -> Vec<email_queue::Model> { email_queue::Entity::find() .filter(email_queue::Column::ToEmail.eq(to_email)) .all(db) .await .unwrap() } /// Create a user with a unique name/address and mark it email-verified. async fn create_verified_user(db: &DatabaseConnection, prefix: &str) -> (user::Model, String) { let unique = Uuid::new_v4().simple().to_string(); let email = format!("{}-{}@example.com", prefix, &unique[..8]); let created = UserService::create_user( db, format!("{}-{}", prefix, &unique[..8]), email.clone(), dummy_hash().await, false, ) .await .unwrap(); // Simulate a clicked validation link. let mut active: user::ActiveModel = created.clone().into(); active.email_verified = Set(true); active.email_verified_at = Set(Some(Utc::now())); let verified = active.update(db).await.unwrap(); (verified, email) } /// Assert an `update_profile` result is the RFC 0041 refusal, and return /// nothing: the message must name the service that owns the flow so a /// consumer hitting it knows where to go. fn assert_refused_towards_email_change_service(result: Result<user::Model, AppError>) { match result { Err(AppError::Validation(msg)) => assert!( msg.contains("EmailChangeService"), "refusal must point at EmailChangeService, got: {msg}" ), other => panic!("expected AppError::Validation, got {other:?}"), } } /// A real email change is refused: nothing is written, nothing is queued, /// and the verification flags stay untouched. #[tokio::test] async fn email_change_is_refused_and_points_at_email_change_service() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); let (user_model, old_email) = create_verified_user(&db, "emailchg").await; let new_email = format!("emailchg-new-{}@example.com", &unique[..8]); let result = UserService::update_profile(&db, user_model.id, None, Some(new_email.clone())).await; assert_refused_towards_email_change_service(result); let reloaded = UserService::get_user_by_id(&db, user_model.id) .await .unwrap() .unwrap(); assert_eq!(reloaded.email, old_email, "email must be unchanged"); assert!( reloaded.email_verified, "a refused change must not un-verify the account" ); assert!(reloaded.email_verified_at.is_some()); assert!( queue_entries_for(&db, &new_email).await.is_empty(), "no email must be queued by update_profile" ); assert!( queue_entries_for(&db, &old_email).await.is_empty(), "no alert must be queued by update_profile" ); reloaded.delete(&db).await.unwrap(); } /// An administrator caller gets the exact same refusal: there is no /// dérogation branch inside `update_profile`, only `admin_force_change`. #[tokio::test] async fn email_change_is_refused_for_admin_accounts_too() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); let (admin_model, admin_email) = create_verified_user(&db, "emailadm").await; // Promote the account to the `admin` role when the RBAC seed is // present: the strongest caller the framework knows about. if let Ok(Some(admin_role)) = crate::services::role_service::RoleService::get_role_by_name(&db, "admin").await { UserService::assign_role_to_user(&db, admin_model.id, admin_role.id) .await .unwrap(); } let target = format!("emailadm-new-{}@example.com", &unique[..8]); let result = UserService::update_profile(&db, admin_model.id, None, Some(target)).await; assert_refused_towards_email_change_service(result); let reloaded = UserService::get_user_by_id(&db, admin_model.id) .await .unwrap() .unwrap(); assert_eq!(reloaded.email, admin_email, "email must be unchanged"); reloaded.delete(&db).await.unwrap(); } /// An invalid address goes through the same branch: refused, no write. #[tokio::test] async fn email_change_invalid_format_rejected_without_side_effects() { let db = test_db().await; let (user_model, email) = create_verified_user(&db, "emailfmt").await; let result = UserService::update_profile(&db, user_model.id, None, Some("not-an-email".into())) .await; assert_refused_towards_email_change_service(result); let reloaded = UserService::get_user_by_id(&db, user_model.id) .await .unwrap() .unwrap(); assert_eq!(reloaded.email, email, "email must be unchanged"); assert!(reloaded.email_verified, "flags must be untouched"); assert!(reloaded.email_verified_at.is_some()); assert!( queue_entries_for(&db, "not-an-email").await.is_empty(), "no email must be queued on a rejected update" ); reloaded.delete(&db).await.unwrap(); } /// An address already owned by another account is refused without /// un-verifying the caller — uniqueness is re-checked by /// `EmailChangeService` at request *and* confirmation time. #[tokio::test] async fn email_change_to_taken_address_rejected_without_unverify() { let db = test_db().await; let (owner, taken_email) = create_verified_user(&db, "emailowner").await; let (caller, caller_email) = create_verified_user(&db, "emailcaller").await; let result = UserService::update_profile(&db, caller.id, None, Some(taken_email.clone())).await; assert_refused_towards_email_change_service(result); let reloaded = UserService::get_user_by_id(&db, caller.id) .await .unwrap() .unwrap(); assert_eq!( reloaded.email, caller_email, "caller email must be unchanged" ); assert!( reloaded.email_verified, "caller must not be un-verified by a rejected change" ); assert!(reloaded.email_verified_at.is_some()); assert!( queue_entries_for(&db, &taken_email).await.is_empty(), "no email must be queued on a rejected update" ); reloaded.delete(&db).await.unwrap(); owner.delete(&db).await.unwrap(); } /// Echoing the current address (exact match after trim) stays a no-op: /// profile forms submit every field, so an unchanged value must not be /// mistaken for a change request. #[tokio::test] async fn email_echo_same_address_is_noop() { let db = test_db().await; let (user_model, email) = create_verified_user(&db, "emailnoop").await; // Exact echo. let echoed = UserService::update_profile(&db, user_model.id, None, Some(email.clone())) .await .unwrap(); assert_eq!(echoed.email, email); assert!( echoed.email_verified, "echoing the same email must keep email_verified=true" ); assert!(echoed.email_verified_at.is_some()); // Whitespace-padded echo: trimmed comparison → still a no-op. let padded = UserService::update_profile(&db, user_model.id, None, Some(format!(" {} ", email))) .await .unwrap(); assert_eq!( padded.email, email, "padded echo must not alter the stored email" ); assert!( padded.email_verified, "padded echo must keep email_verified=true" ); assert!(padded.email_verified_at.is_some()); assert!( queue_entries_for(&db, &email).await.is_empty(), "no email must be queued on a no-op echo" ); padded.delete(&db).await.unwrap(); } /// Non-regression: the signature is unchanged by RFC 0041, so consumer /// code keeps compiling. All four call shapes are exercised, the two /// carrying an email echoing the current address. #[tokio::test] async fn update_profile_accepts_the_four_pre_rfc_call_shapes() { let db = test_db().await; let unique = Uuid::new_v4().simple().to_string(); let (user_model, email) = create_verified_user(&db, "emailsig").await; // (None, None): nothing to do. let untouched = UserService::update_profile(&db, user_model.id, None, None) .await .unwrap(); assert_eq!(untouched.username, user_model.username); // (None, Some(current)): no-op echo. UserService::update_profile(&db, user_model.id, None, Some(email.clone())) .await .unwrap(); // (Some, None): username-only rename. let renamed_name = format!("emailsig-r-{}", &unique[..8]); let renamed = UserService::update_profile(&db, user_model.id, Some(renamed_name.clone()), None) .await .unwrap(); assert_eq!(renamed.username, renamed_name); // (Some, Some(current)): rename with the email field echoed back. let renamed_again = format!("emailsig-r2-{}", &unique[..8]); let final_user = UserService::update_profile( &db, user_model.id, Some(renamed_again.clone()), Some(email.clone()), ) .await .unwrap(); assert_eq!(final_user.username, renamed_again); assert_eq!(final_user.email, email); assert!(final_user.email_verified); final_user.delete(&db).await.unwrap(); } }
GitRust