//! Authentication, session management, and account security. //! //! Passwords are hashed with Argon2id (random salt per hash). Sessions use //! `tower-sessions` with ID regeneration on login (prevents fixation) and //! full flush on logout. Each login creates a tracked session row in //! `user_sessions` for remote revocation from the security dashboard. //! //! Two-factor authentication supports both TOTP (time-based one-time //! passwords via `totp-rs`) and WebAuthn passkeys (via `webauthn-rs`). //! Account lockout is enforced after repeated failed login attempts, with //! progressive delays tracked by `failed_login_attempts` and `locked_until` //! on the user row. New-device login notifications are sent via Postmark //! when enabled. //! //! Extractors: [`AuthUser`] (required login), [`MaybeUserUnverified`] (optional, //! no revocation check — public read-only pages only), [`MaybeUserVerified`] //! (optional with revocation check — anywhere identity actually gates behavior), //! [`AdminUser`] (admin-only, hides routes with 404). use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Algorithm, Argon2, Params, Version, }; use axum::{ extract::FromRequestParts, http::{header::HeaderMap, request::Parts}, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tower_sessions::Session; use std::time::Instant; use crate::config::Config; use crate::constants; use crate::db::{self, UserId, UserSessionId, Username}; use crate::error::{AppError, ResultExt}; use crate::helpers::constant_time_compare; /// Session key for storing user data const USER_SESSION_KEY: &str = "user"; /// Session key for linking to the `user_sessions` tracking row. pub const SESSION_TRACKING_KEY: &str = "session_tracking_id"; /// User data stored in session #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SessionUser { pub id: UserId, pub username: Username, pub email: String, pub display_name: Option, #[serde(default)] pub can_create_projects: bool, #[serde(default)] pub suspended: bool, #[serde(default)] pub is_admin: bool, #[serde(default)] pub is_fan_plus: bool, #[serde(default)] pub creator_tier: Option, #[serde(default)] pub deactivated: bool, #[serde(default)] pub is_sandbox: bool, } impl SessionUser { /// Build a `SessionUser` from a DB user row + async lookups for fan_plus and creator_tier. /// /// Used by all login paths (password, passkey, 2FA, email link) except the join wizard /// (which uses hardcoded defaults for a freshly created account). pub async fn from_db_user( user: db::DbUser, pool: &sqlx::PgPool, admin_user_id: Option, ) -> Self { let suspended = user.is_suspended(); let deactivated = user.is_deactivated(); let is_admin = admin_user_id == Some(user.id); let is_fan_plus = db::fan_plus::is_fan_plus_active(pool, user.id) .await .unwrap_or(false); let creator_tier = db::creator_tiers::get_active_creator_tier(pool, user.id) .await .ok() .flatten(); Self { id: user.id, username: user.username, email: user.email.into_inner(), display_name: user.display_name, can_create_projects: user.can_create_projects, suspended, is_admin, is_fan_plus, creator_tier, deactivated, is_sandbox: user.is_sandbox, } } /// Returns `Err(Forbidden)` if the user is a sandbox account. /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.). pub fn check_not_sandbox(&self) -> Result<(), AppError> { if self.is_sandbox { Err(AppError::Forbidden) } else { Ok(()) } } /// Returns `Err(Forbidden)` if the user is suspended or deactivated. /// Call at the top of write routes that suspended/deactivated users should not access. pub fn check_not_suspended(&self) -> Result<(), AppError> { if self.suspended || self.deactivated { Err(AppError::Forbidden) } else { Ok(()) } } } /// Read the cached `SessionUser` from a session, if one is logged in. /// /// Coarse read: it does NOT revalidate the session-tracking row (the way the /// `AuthUser` extractor does). Intended for middleware-level pre-filters like /// the site access gate, where the per-route `AuthUser` extractor still /// enforces full validation downstream. Returns `None` for anonymous sessions. pub async fn session_user(session: &Session) -> Option { session.get::(USER_SESSION_KEY).await.ok().flatten() } /// Extractor for authenticated users - returns error if not logged in. /// /// Specialized to `AppState` (not generic `S`) to access the DB pool for /// session tracking validation. If the session's tracking row has been /// deleted (revoked), the session is flushed and Unauthorized is returned. /// Legacy sessions without a tracking ID are allowed through until they /// expire naturally. pub struct AuthUser(pub SessionUser); impl FromRequestParts for AuthUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; let user: SessionUser = session .get(USER_SESSION_KEY) .await .context("session error")? .ok_or(AppError::Unauthorized)?; // Validate session tracking (skip for legacy sessions without tracking ID). // Uses an in-memory cache to avoid hitting the DB on every request — // if this session was validated within SESSION_TOUCH_CACHE_SECS, skip the query. let mut user = user; if let Ok(Some(tracking_id)) = session .get::(SESSION_TRACKING_KEY) .await { let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS); let cached = state.session_cache.get(&tracking_id) .map(|entry| entry.elapsed() < cache_ttl) .unwrap_or(false); if !cached { let result = match db::sessions::touch_session(&state.db, tracking_id).await { Ok(r) => r, Err(e) => { tracing::warn!(error = ?e, "session touch failed, invalidating"); db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None } } }; if !result.valid { state.session_cache.remove(&tracking_id); let _ = session.flush().await; return Err(AppError::Unauthorized); } // If the user's live DB state differs from the session, update it. // touch_session returns suspended, can_create_projects, is_fan_plus, // and creator_tier in a single query (no extra round-trips). let live_tier: Option = result.creator_tier.as_deref().and_then(|s| s.parse().ok()); if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier { user.suspended = result.suspended; user.is_fan_plus = result.is_fan_plus; user.can_create_projects = result.can_create_projects; user.creator_tier = live_tier; if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await { tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state"); } } state.session_cache.insert(tracking_id, Instant::now()); } } // Record user_id in the current span so all downstream logs // (DB queries, error handlers, etc.) include it automatically. tracing::Span::current().record("user_id", tracing::field::display(&user.id)); Ok(AuthUser(user)) } } /// Extractor for optional authenticated users — returns None if not logged in. /// /// **DANGER — this extractor does NOT validate the session against the database.** /// A revoked session (user clicked "log out everywhere", account suspended, /// session row deleted) will still resolve to `Some(SessionUser)` here until /// the cookie naturally expires. The name carries the warning: any handler /// that uses this type accepts that consequence. /// /// Use ONLY for cheap anonymous-or-logged-in rendering on public read-only /// pages where displaying stale identity is acceptable (blog views, docs, /// discover feed). For any handler that: /// - modifies data, /// - gates paid content or downloads, /// - issues OAuth tokens / grants, /// - exposes account-private information, /// /// use [`AuthUser`] (required login) or [`MaybeUserVerified`] (optional login /// with revocation check) instead. pub struct MaybeUserUnverified(pub Option); impl FromRequestParts for MaybeUserUnverified where S: Send + Sync, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; let user: Option = session .get(USER_SESSION_KEY) .await .context("session error")?; // Short-circuit legacy sessions (USER_SESSION_KEY present without a // SESSION_TRACKING_KEY) to anonymous. Without this, a pre-tracking // session quietly survives `/logout-everywhere` — that sweep deletes // user_sessions rows, but a legacy session has no row to delete and // would keep rendering as logged-in on every Unverified extractor // until the cookie naturally expires. if user.is_some() { let tracking: Option = session .get(SESSION_TRACKING_KEY) .await .ok() .flatten(); if tracking.is_none() { return Ok(MaybeUserUnverified(None)); } } Ok(MaybeUserUnverified(user)) } } /// Extractor for optional authenticated users WITH revocation check. /// /// Like [`MaybeUserUnverified`] but runs the same session-tracking validation /// as [`AuthUser`]: if the tracking row has been deleted (revoked) or the /// account is suspended, the session is flushed and `None` is returned (the /// request continues as anonymous rather than 401, since the handler chose /// "optional auth"). Legacy sessions without a tracking ID pass through. /// /// Costs one cached `touch_session` query per request (TTL = `SESSION_TOUCH_CACHE_SECS`). /// Prefer this over `MaybeUserUnverified` anywhere the identity actually gates /// behavior — paid content access, OAuth flows, download grants, comments, /// or anything that writes to the DB on behalf of the user. pub struct MaybeUserVerified(pub Option); impl FromRequestParts for MaybeUserVerified { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let session = parts .extensions .get::() .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; let Some(mut user): Option = session .get(USER_SESSION_KEY) .await .context("session error")? else { return Ok(MaybeUserVerified(None)); }; if let Ok(Some(tracking_id)) = session .get::(SESSION_TRACKING_KEY) .await { let cache_ttl = std::time::Duration::from_secs(constants::SESSION_TOUCH_CACHE_SECS); let cached = state.session_cache.get(&tracking_id) .map(|entry| entry.elapsed() < cache_ttl) .unwrap_or(false); if !cached { let result = match db::sessions::touch_session(&state.db, tracking_id).await { Ok(r) => r, Err(e) => { tracing::warn!(error = ?e, "session touch failed in MaybeUserVerified, treating as anonymous"); db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false, is_fan_plus: false, creator_tier: None } } }; if !result.valid { state.session_cache.remove(&tracking_id); let _ = session.flush().await; return Ok(MaybeUserVerified(None)); } let live_tier: Option = result.creator_tier.as_deref().and_then(|s| s.parse().ok()); if user.suspended != result.suspended || user.is_fan_plus != result.is_fan_plus || user.can_create_projects != result.can_create_projects || user.creator_tier != live_tier { user.suspended = result.suspended; user.is_fan_plus = result.is_fan_plus; user.can_create_projects = result.can_create_projects; user.creator_tier = live_tier; if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await { tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state"); } } state.session_cache.insert(tracking_id, Instant::now()); } } tracing::Span::current().record("user_id", tracing::field::display(&user.id)); Ok(MaybeUserVerified(Some(user))) } } /// Extractor for admin users - returns NotFound (hides admin routes) if not admin. /// /// Combines `AuthUser` session check with `require_admin` config check into a /// single type-safe extractor, eliminating per-handler `require_admin()` calls. pub struct AdminUser(pub SessionUser); impl FromRequestParts for AdminUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?; require_admin(&user, &state.config)?; Ok(AdminUser(user)) } } /// Extractor for internal service-to-service auth (CLI SSH server → MNW API). /// /// Validates `Authorization: Bearer {token}` against `config.cli_service_token`. /// Returns 401 if the token is missing/invalid, 503 if the token is not configured. pub struct ServiceAuth; impl FromRequestParts for ServiceAuth { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &crate::AppState, ) -> Result { let expected = state.config.cli_service_token.as_deref().ok_or_else(|| { AppError::ServiceUnavailable("Internal API not configured".to_string()) })?; let header = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(AppError::Unauthorized)?; if !constant_time_compare(header, expected) { return Err(AppError::Unauthorized); } Ok(ServiceAuth) } } /// Hash a password using Argon2id. /// /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms). /// Verification auto-detects params from the hash string, so no feature flag needed there. pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); #[cfg(feature = "fast-tests")] let params = Params::new(8 * 1024, 1, 1, None) .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?; #[cfg(not(feature = "fast-tests"))] let params = Params::new(46 * 1024, 2, 1, None) .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 params: {e}")))?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let hash = argon2 .hash_password(password.as_bytes(), &salt) .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing: {e}")))?; Ok(hash.to_string()) } /// Verify a password against a stored PHC-encoded Argon2 hash. /// /// Derives the verifier from the stored hash's own algorithm/version/params /// rather than relying on `Argon2::default()`. The two are functionally /// equivalent today (the `PasswordVerifier` trait reads parameters from /// the parsed hash, not the instance) but explicit derivation pins our /// boundary: this function only verifies Argon2 family hashes, anything /// else fails out at `Algorithm::try_from`. Forward-compatible with a /// future algorithm migration — when one lands, add a dispatch table /// instead of swapping the default instance under the verifier's feet. pub fn verify_password(password: &str, hash: &str) -> Result { let parsed_hash = PasswordHash::new(hash) .map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash: {e}")))?; let algorithm = Algorithm::try_from(parsed_hash.algorithm) .map_err(|e| AppError::Internal(anyhow::anyhow!("unexpected password hash algorithm: {e}")))?; let version = parsed_hash .version .map(Version::try_from) .transpose() .map_err(|e| AppError::Internal(anyhow::anyhow!("unexpected password hash version: {e}")))? .unwrap_or(Version::V0x13); let params = Params::try_from(&parsed_hash) .map_err(|e| AppError::Internal(anyhow::anyhow!("parse password hash params: {e}")))?; Ok(Argon2::new(algorithm, version, params) .verify_password(password.as_bytes(), &parsed_hash) .is_ok()) } /// Store user in session with session regeneration to prevent fixation attacks #[tracing::instrument(skip_all, fields(user_id = %user.id))] pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> { // Regenerate session ID to prevent session fixation attacks // This creates a new session ID while preserving session data session .cycle_id() .await .context("session cycle")?; // Regenerate CSRF token so pre-auth tokens can't be used post-auth let new_csrf = crate::csrf::generate_token(); session .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf) .await .context("csrf token insert")?; session .insert(USER_SESSION_KEY, user) .await .context("session insert")?; Ok(()) } /// Destroy entire session on logout to prevent session reuse #[tracing::instrument(skip_all)] pub async fn logout_user(session: &Session) -> Result<(), AppError> { // Flush the entire session to destroy all data and invalidate session ID session .flush() .await .context("session flush")?; Ok(()) } /// Record a new session in `user_sessions` and store the tracking ID in session data. /// Call this after `login_user()` in every login path. #[tracing::instrument(skip_all, fields(user_id = %user_id))] pub async fn track_session( session: &Session, pool: &PgPool, user_id: UserId, headers: &HeaderMap, ) -> Result<(), AppError> { let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::()); let ip = crate::helpers::extract_client_ip(headers); let tracking_id = db::sessions::create_user_session(pool, user_id, user_agent.as_deref(), ip.as_deref()).await?; session .insert(SESSION_TRACKING_KEY, tracking_id) .await .context("session insert")?; Ok(()) } /// Send a new-device login notification if the user has other active sessions. /// /// Fire-and-forget — spawns a background task. Only sends if the user has opted in /// and has more than one active session (meaning this is a new device). pub async fn maybe_send_login_notification( state: &crate::AppState, user_id: UserId, email: &str, display_name: Option<&str>, enabled: bool, headers: &HeaderMap, ) { if !enabled { return; } let session_count = match db::sessions::count_user_sessions(&state.db, user_id).await { Ok(n) => n, Err(e) => { tracing::warn!("Failed to count sessions for login notification: {e}"); return; } }; if session_count <= 1 { return; } let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()) .map(|s| s.chars().take(constants::USER_AGENT_MAX_LENGTH).collect::()); let ip = crate::helpers::extract_client_ip(headers); let unsub_url = crate::email::generate_unsubscribe_url( &state.config.host_url, user_id, crate::email::UnsubscribeAction::Login, &user_id.to_string(), &state.config.signing_secret, ); let email = email.to_string(); let display_name = display_name.map(String::from); crate::helpers::spawn_email!(state, "login notification", |email_client| { email_client.send_new_login_notification( &email, display_name.as_deref(), user_agent.as_deref(), ip.as_deref(), Some(&unsub_url), ) }); } /// Check if a password appears in the HaveIBeenPwned breached passwords database. /// Uses k-anonymity: only the first 5 characters of the SHA-1 hash are sent. /// Returns Some(count) if breached, None if clean or API unavailable. /// /// This check is advisory (it never blocks a password change), so a lookup /// failure fails open — but it must not fail *silently*. A network blip or /// HIBP outage that disables breach checking is logged at WARN so the gap is /// visible in observability rather than disappearing into a bare `?`. pub async fn check_password_breach(password: &str) -> Option { use sha1::{Sha1, Digest}; let hash = hex::encode(Sha1::digest(password.as_bytes())).to_uppercase(); let (prefix, suffix) = hash.split_at(5); let url = format!("https://api.pwnedpasswords.com/range/{}", prefix); let response = match reqwest::Client::new() .get(&url) .header("User-Agent", "MakeNotWork-Security-Check") .header("Add-Padding", "true") .timeout(std::time::Duration::from_secs(3)) .send() .await { Ok(resp) => resp, Err(e) => { tracing::warn!(error = %e, "HIBP breach lookup failed (network/timeout); breach check skipped (fail-open)"); return None; } }; let response = match response.text().await { Ok(body) => body, Err(e) => { tracing::warn!(error = %e, "HIBP breach lookup: could not read response body; breach check skipped (fail-open)"); return None; } }; for line in response.lines() { let mut parts = line.splitn(2, ':'); if let (Some(hash_suffix), Some(count)) = (parts.next(), parts.next()) && hash_suffix.trim() == suffix { return count.trim().parse().ok(); } } None } /// Check if a user is the admin. Returns NotFound to hide admin routes from non-admins. pub fn require_admin(user: &SessionUser, config: &Config) -> Result<(), AppError> { match config.admin_user_id { Some(admin_id) if admin_id == user.id => Ok(()), _ => Err(AppError::NotFound), } } #[cfg(test)] mod tests { use super::*; #[test] fn hash_password_produces_valid_hash() { let hash = hash_password("test_password_123").unwrap(); // Argon2 hashes start with $argon2 assert!(hash.starts_with("$argon2")); } #[test] fn verify_password_correct() { let hash = hash_password("correct_horse").unwrap(); assert!(verify_password("correct_horse", &hash).unwrap()); } #[test] fn verify_password_wrong() { let hash = hash_password("correct_horse").unwrap(); assert!(!verify_password("wrong_horse", &hash).unwrap()); } #[test] fn hash_password_different_each_time() { let h1 = hash_password("same_password").unwrap(); let h2 = hash_password("same_password").unwrap(); // Different salts should produce different hashes assert_ne!(h1, h2); } #[test] fn require_admin_with_admin_id() { let user = SessionUser { id: "00000000-0000-0000-0000-000000000001".parse::().unwrap(), username: Username::from_trusted("admin".to_string()), email: "admin@example.com".to_string(), display_name: None, can_create_projects: true, suspended: false, is_admin: true, is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, }; let config = Config { host: "127.0.0.1".parse().unwrap(), port: 3000, database_url: "postgres://test".to_string(), host_url: std::sync::Arc::from("http://localhost:3000"), signing_secret: "secret".to_string(), storage: None, synckit_storage: None, stripe: None, admin_user_id: Some(user.id), synckit_jwt_secret: None, scan: None, git_repos_path: None, postmark_webhook_token: None, postmark_broadcast_webhook_token: None, git_ssh_host: None, mt_base_url: None, fan_plus_price_id: None, creator_tier_prices: std::collections::HashMap::new(), creator_tier_annual_prices: std::collections::HashMap::new(), creator_tier_founder_prices: std::collections::HashMap::new(), creator_tier_founder_annual_prices: std::collections::HashMap::new(), creator_founder_window_open: false, build_trigger_token: None, build_host_linux: None, build_host_darwin: None, cdn_base_url: None, postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, access_gate: crate::config::AccessGate::Open, sso: None, }; assert!(require_admin(&user, &config).is_ok()); } #[tokio::test] #[ignore] // Requires network access — run manually async fn check_password_breach_known_breached() { let result = check_password_breach("password").await; assert!(result.is_some()); assert!(result.unwrap() > 0); } #[tokio::test] #[ignore] // Requires network access — run manually async fn check_password_breach_unknown() { // A random 64-char string should not appear in any breach database let random_pw = "xK9m2Qp7vL4nR8wJ3sY6dF1gH5bT0cU9eA2iO7lN4mP8qW3rX6zV1yB5jD0fG"; let result = check_password_breach(random_pw).await; assert!(result.is_none()); } #[test] fn require_admin_without_admin_id() { let user = SessionUser { id: UserId::new(), username: Username::from_trusted("notadmin".to_string()), email: "user@example.com".to_string(), display_name: None, can_create_projects: false, suspended: false, is_admin: false, is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, }; let config = Config { host: "127.0.0.1".parse().unwrap(), port: 3000, database_url: "postgres://test".to_string(), host_url: std::sync::Arc::from("http://localhost:3000"), signing_secret: "secret".to_string(), storage: None, synckit_storage: None, stripe: None, admin_user_id: None, synckit_jwt_secret: None, scan: None, git_repos_path: None, postmark_webhook_token: None, postmark_broadcast_webhook_token: None, git_ssh_host: None, mt_base_url: None, fan_plus_price_id: None, creator_tier_prices: std::collections::HashMap::new(), creator_tier_annual_prices: std::collections::HashMap::new(), creator_tier_founder_prices: std::collections::HashMap::new(), creator_tier_founder_annual_prices: std::collections::HashMap::new(), creator_founder_window_open: false, build_trigger_token: None, build_host_linux: None, build_host_darwin: None, cdn_base_url: None, postmark_inbound_webhook_token: None, internal_shared_secret: None, cli_service_token: None, wam_url: None, access_gate: crate::config::AccessGate::Open, sso: None, }; assert!(require_admin(&user, &config).is_err()); } // ── Guard function tests ── fn make_user(is_sandbox: bool, suspended: bool, deactivated: bool) -> SessionUser { SessionUser { id: UserId::new(), username: Username::from_trusted("testuser".to_string()), email: "test@example.com".to_string(), display_name: None, can_create_projects: false, suspended, is_admin: false, is_fan_plus: false, creator_tier: None, deactivated, is_sandbox, } } #[test] fn check_not_sandbox_allows_normal_user() { let user = make_user(false, false, false); assert!(user.check_not_sandbox().is_ok()); } #[test] fn check_not_sandbox_blocks_sandbox() { let user = make_user(true, false, false); assert!(user.check_not_sandbox().is_err()); } #[test] fn check_not_suspended_allows_normal_user() { let user = make_user(false, false, false); assert!(user.check_not_suspended().is_ok()); } #[test] fn check_not_suspended_blocks_suspended() { let user = make_user(false, true, false); assert!(user.check_not_suspended().is_err()); } #[test] fn check_not_suspended_blocks_deactivated() { let user = make_user(false, false, true); assert!(user.check_not_suspended().is_err()); } #[test] fn check_not_suspended_blocks_both() { let user = make_user(false, true, true); assert!(user.check_not_suspended().is_err()); } }