pending_email_change.rs 171 lignes · 5741 octets
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// PendingEmailChange entity model.
///
/// Holds an email-change request that has **not** been applied yet. Until the
/// confirmation is submitted, `users.email` keeps its current — verified —
/// value: the account is never left pointing at an unproven address.
///
/// # Security features
///
/// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the
///   new address) and `report_token_hash` (abuse report, mailed to the current
///   address). Plain tokens are never stored.
/// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default).
/// - Single use: `used_at` is set when the change is applied.
/// - Cascade delete: rows are removed when the user is deleted.
///
/// # Request lifecycle
///
/// 1. **Creation**: two UUID v4 tokens are generated, hashed and stored with an
///    expiration; the plain tokens are only ever sent by email.
/// 2. **Confirmation**: the owner of `new_email` submits the confirmation form,
///    `users.email` is written and `used_at` is set.
/// 3. **Report**: the owner of the *current* address may flag the request —
///    `reported_at` is set, but the change is never blocked.
/// 4. **Cleanup**: rows expired for longer than the retention window are
///    hard-deleted (the durable audit trail lives in `audit_logs`).
#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "pending_email_changes")]
pub struct Model {
    /// Unique identifier for the request.
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: Uuid,

    /// User this request belongs to.
    #[sea_orm(indexed)]
    pub user_id: Uuid,

    /// Candidate address. Written to `users.email` only on confirmation.
    pub new_email: String,

    /// SHA-256 hash of the confirmation token (sent to `new_email`).
    #[sea_orm(unique, indexed)]
    pub token_hash: String,

    /// SHA-256 hash of the report token (sent to the current address).
    #[sea_orm(unique, indexed)]
    pub report_token_hash: String,

    /// Expiration timestamp.
    #[sea_orm(indexed)]
    pub expires_at: DateTime<Utc>,

    /// Set when the change has been applied. `None` while pending.
    pub used_at: Option<DateTime<Utc>>,

    /// Set when the current address owner flagged the request as suspicious.
    /// Informational only — it never blocks the change.
    pub reported_at: Option<DateTime<Utc>>,

    /// Creation timestamp.
    pub created_at: DateTime<Utc>,
}

/// SeaORM relations for the PendingEmailChange entity.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
    /// Relationship to User entity.
    #[sea_orm(
        belongs_to = "super::user::Entity",
        from = "Column::UserId",
        to = "super::user::Column::Id",
        on_delete = "Cascade"
    )]
    User,
}

impl Related<super::user::Entity> for Entity {
    fn to() -> RelationDef {
        Relation::User.def()
    }
}

impl ActiveModelBehavior for ActiveModel {}

impl Model {
    /// Returns `true` once the expiration timestamp has passed.
    pub fn is_expired(&self) -> bool {
        Utc::now() > self.expires_at
    }

    /// Returns `true` once the change has been applied.
    pub fn is_used(&self) -> bool {
        self.used_at.is_some()
    }

    /// Returns `true` while the request can still be confirmed.
    pub fn is_pending(&self) -> bool {
        !self.is_used() && !self.is_expired()
    }

    /// Returns `true` once the current address owner flagged the request.
    ///
    /// Purely informational: a reported request stays confirmable (see
    /// [`EmailChangeService::report_suspicious`](crate::services::email_change_service::EmailChangeService::report_suspicious)).
    pub fn is_reported(&self) -> bool {
        self.reported_at.is_some()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;

    /// Build a model whose `expires_at` / `used_at` are set by the caller.
    fn model(expires_in_hours: i64, used: bool) -> Model {
        let now = Utc::now();
        Model {
            id: Uuid::new_v4(),
            user_id: Uuid::new_v4(),
            new_email: "candidate@example.test".to_string(),
            token_hash: "a".repeat(64),
            report_token_hash: "b".repeat(64),
            expires_at: now + Duration::hours(expires_in_hours),
            used_at: if used { Some(now) } else { None },
            reported_at: None,
            created_at: now,
        }
    }

    #[test]
    fn truth_table_pending_used_expired() {
        // fresh + unused → pending
        let fresh = model(24, false);
        assert!(!fresh.is_expired());
        assert!(!fresh.is_used());
        assert!(fresh.is_pending());

        // fresh + used → not pending
        let used = model(24, true);
        assert!(!used.is_expired());
        assert!(used.is_used());
        assert!(!used.is_pending());

        // expired + unused → not pending
        let expired = model(-1, false);
        assert!(expired.is_expired());
        assert!(!expired.is_used());
        assert!(!expired.is_pending());

        // expired + used → not pending
        let both = model(-1, true);
        assert!(both.is_expired());
        assert!(both.is_used());
        assert!(!both.is_pending());
    }

    #[test]
    fn reported_flag_does_not_affect_pending() {
        let mut reported = model(24, false);
        assert!(!reported.is_reported());
        reported.reported_at = Some(Utc::now());
        assert!(reported.is_reported());
        // RFC 0041: a report alerts, it never blocks.
        assert!(reported.is_pending());
    }
}