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();
    }
}