//! Invite-by-email orchestration for `POST /api/grants` with //! `subject.type = "email"`. //! //! Two-step API (kept separate so the handler can interleave the standard //! grant-creation step in between): //! //! 1. [`resolve_or_create_recipient`] — normalise the email, apply the //! allowlist + kill-switch checks, then look up or lazily provision //! an external user. Returns the resolved [`User`] entity. //! 2. [`issue_invitation`] — mint a magic-link token targeting the //! shared resource, build the `/magic/v1/{token}` URL, and send the //! invitation email through the wired `EmailSender`. //! //! Step 2 is gated by [`magic_link_eligibility`] — OIDC users are //! unconditionally rejected (audit `oidc_user`); password users are //! rejected by default (`has_password`) but allowed when //! `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`. Rejected //! invitations still result in the grant being created — the recipient //! sees the shared resource in their normal "Shared with me" view — //! only the courtesy notification mail is suppressed. //! //! # Enumeration defense //! //! v1 awaits the SMTP send synchronously. A malicious caller can in //! theory measure response times to distinguish "new external user //! provisioned + mail sent" from "existing internal user, no mail" — //! a single-bit oracle. The plan defers full constant-time defense //! (fire-and-forget spawn, dummy SMTP latency on no-op paths) to PR 12. use std::sync::Arc; use askama::Template; use crate::application::ports::email_sender::{EmailMessage, EmailSender}; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::user_lifecycle_service::UserLifecycleService; use crate::common::config::MagicLinkConfig; use crate::common::errors::{DomainError, ErrorKind}; use crate::common::locale::Locale; use crate::domain::entities::magic_link_token::{ MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken, }; use crate::domain::entities::user::{User, UserRole}; use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository; use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError}; use crate::domain::services::authorization::{Resource, ResourceKind}; use crate::domain::services::email_normalize::normalize_email; use crate::infrastructure::repositories::pg::UserPgRepository; /// Eligibility decision for a user to receive a magic-link. /// /// Returned by [`magic_link_eligibility`]. The `Reject` arm carries a /// **stable** audit-reason key (`"oidc_user"`, `"has_password"`, /// `"account_deactivated"`) — log aggregators key off this, do not /// repurpose existing values. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Eligibility { Allow, Reject(&'static str), } /// Decide whether to mint a magic-link for the given user. /// /// Precedence ladder (PR 19): /// /// 1. **OIDC linked** → always reject with `"oidc_user"`. The IdP is the /// security boundary and may enforce MFA that magic-link would /// bypass. The `open_to_password_users` flag has **no effect**. /// 2. **Has a password configured** → reject with `"has_password"` by /// default. Allow when `open_to_password_users` is `true` (lenient /// mode — operator opt-in via env, accepting that mailbox compromise /// becomes equivalent to password compromise). /// 3. **No credential at all** (the typical external user or /// fresh email-only signup) → allow. /// /// Account-deactivation is **not** checked here — `send_login_link` / /// `issue_invitation` handle it separately because the rejection reason /// (`"account_deactivated"`) is unrelated to credential state. pub fn magic_link_eligibility(user: &User, open_to_password_users: bool) -> Eligibility { if user.is_oidc_user() { return Eligibility::Reject("oidc_user"); } if user.has_password() { return if open_to_password_users { Eligibility::Allow } else { Eligibility::Reject("has_password") }; } Eligibility::Allow } pub struct MagicLinkInviteService { user_storage: Arc, magic_link_repo: Arc, email_sender: Arc, user_lifecycle: Arc, i18n: Arc, /// Used to validate a stored `preferred_locale` at render time — /// a code that's no longer in the registry (e.g. operator removed /// `pl.json`) falls back to the server default instead of raising /// a translation error. locale_registry: Arc, magic_link_cfg: MagicLinkConfig, /// Public base URL of this OxiCloud instance — used to build the /// `/magic/v1/{token}` invitation link. Sourced from /// `AppConfig::base_url()` at DI time. public_base_url: String, } impl MagicLinkInviteService { #[allow(clippy::too_many_arguments)] pub fn new( user_storage: Arc, magic_link_repo: Arc, email_sender: Arc, user_lifecycle: Arc, i18n: Arc, locale_registry: Arc, magic_link_cfg: MagicLinkConfig, public_base_url: String, ) -> Self { Self { user_storage, magic_link_repo, email_sender, user_lifecycle, i18n, locale_registry, magic_link_cfg, public_base_url, } } /// Resolve the recipient's preferred locale into a usable `Locale`. /// Returns the server default when: /// - `preferred_locale` is `None` (the common case for pre-PR-C /// users and recipients who never picked a language), /// - the stored code no longer resolves against the registry /// (e.g. operator removed a locale file after the row was /// written, or a future schema migration relaxed the CHECK). fn locale_for(&self, user: &User) -> Locale { user.preferred_locale() .and_then(|code| self.locale_registry.parse(code)) .unwrap_or_else(|| self.locale_registry.default_locale().clone()) } /// Resolve the email to an existing user, or lazily provision a new /// external user. Returns the resolved [`User`]. /// /// Errors: /// - `InvalidInput` — email failed normalisation (malformed / too long). /// - `AccessDenied` — email-grant kill switch is off /// (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`) and no matching user /// exists, OR the email's domain isn't in the allowlist. /// - any propagated repo error. pub async fn resolve_or_create_recipient( &self, raw_email: &str, inviter_id: Option, ) -> Result { let normalised = normalize_email(raw_email).map_err(|e| { DomainError::new(ErrorKind::InvalidInput, "MagicLinkInvite", format!("{}", e)) })?; // Fast path: existing user with this email — works for both // internal (was previously created via normal registration) and // external (previous invitation re-sharing) cases. We do NOT // touch `preferred_locale` on an existing row; the recipient's // own choice (or a previously-inherited value) wins. match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await { Ok(user) => Ok(user), Err(UserRepositoryError::NotFound(_)) => { // Best-effort inviter locale lookup. A failure here // (deleted inviter row, transient DB blip) is non-fatal // — the recipient is created with NULL locale and // resolves to the server default like any pre-PR-C row. let inviter_locale = if let Some(uid) = inviter_id { match UserRepository::get_user_by_id(&*self.user_storage, uid).await { Ok(u) => u.preferred_locale().map(str::to_string), Err(_) => None, } } else { None }; self.create_external_user(&normalised, inviter_locale).await } Err(e) => Err(DomainError::from(e)), } } /// Lazy provisioning path. Runs the two policy guards (kill switch /// and per-domain allowlist) before touching the DB. /// /// `inviter_locale` is the inviter's `preferred_locale` if any — /// PR C inherits it into the new external user's row so the /// invitation mail (and any subsequent emails to the recipient) /// arrive in a language the inviter likely shares with them. The /// recipient can override later via the language switcher. async fn create_external_user( &self, normalised_email: &str, inviter_locale: Option, ) -> Result { if !self.magic_link_cfg.allow_external_users { return Err(DomainError::new( ErrorKind::AccessDenied, "MagicLinkInvite", "Creating external users is disabled on this server \ (OXICLOUD_ALLOW_EXTERNAL_USERS=false)" .to_string(), )); } if !self.magic_link_cfg.is_email_allowed(normalised_email) { return Err(DomainError::new( ErrorKind::AccessDenied, "MagicLinkInvite", format!( "Email domain is not in the allowlist (OXICLOUD_EXTERNAL_EMAIL_DOMAINS); \ refusing to invite {}", normalised_email, ), )); } // External users are created without a username or password. // `password_hash IS NULL` is the canonical no-password marker. let mut user = User::new( normalised_email.to_string(), None, None, None, None, UserRole::User, 0, true, ) .map_err(|e| { DomainError::new( ErrorKind::InvalidInput, "MagicLinkInvite", format!("invalid external user data: {}", e), ) })?; // PR C: inherit the inviter's preferred locale at row creation // (decision 6 in the plan). Treated as advisory — frequently // wrong, but the recipient can override via the language // switcher, and the bilingual email partial ships English // alongside any non-English copy as a safety net. if let Some(locale) = inviter_locale { user.set_preferred_locale(Some(locale)); } let saved = UserRepository::create_user(&*self.user_storage, user.clone()) .await .map_err(DomainError::from)?; // Fire the user-lifecycle dispatcher — `on_user_created` lights // up audit + future external-identity provenance bookkeeping. // Errors are logged-and-continued by the dispatcher's // `dispatch_created` per the lifecycle contract. self.user_lifecycle.dispatch_created(&saved).await; Ok(saved) } /// Mint a magic-link token targeting the resource and email the /// invitation link. Caller is expected to have already created the /// grant rows. /// /// `inviter` is interpolated into the subject line ("Alice shared /// with you on OxiCloud") and body ("Alice shared a /// folder with you. Open it by..."). Two forms are computed via /// [`User::display_full`] — the short form goes into the subject /// (keeps inbox-row width sane), the email-decorated form goes /// into the body where the extra identifier helps the recipient /// place who's reaching out. pub async fn issue_invitation( &self, recipient: &User, inviter: &User, resource: Resource, ) -> Result<(), DomainError> { // The grant is in place either way; only mint a magic link when // the recipient is magic-link-eligible. OIDC-linked users never // get one (IdP is the security boundary); password users get // one only when the operator opted into lenient mode via // `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`. Either way // they see the grant in their normal "Shared with me" view — // the mail is purely a notification convenience. if let Eligibility::Reject(reason) = magic_link_eligibility(recipient, self.magic_link_cfg.open_to_password_users) { tracing::info!( target: "audit", event = "magic_link.invitation_suppressed", reason = reason, user_id = %recipient.id(), username = %recipient.display_for_audit(), "📭 invitation mail suppressed: '{}' is not magic-link-eligible ({})", recipient.display_for_audit(), reason, ); return Ok(()); } let (kind, resource_id) = match resource { Resource::Folder(id) => (MagicLinkResourceKind::Folder, id), Resource::File(id) => (MagicLinkResourceKind::File, id), // Drive / Calendar / AddressBook sharing is out-of-band for // the magic-link flow. Drive shares land through // `/api/drives/{id}/members`; Calendar / AddressBook shares // through the Round-3 `/api/(calendars|address-books)/{id}/shares` // endpoints. The DTOs accept every `Resource` variant on // the wire (see `ResourceTypeDto`) but only file/folder // grants trigger an invitation email. Treating the other // arms as audit-logged suppressed no-ops keeps the grant // in place while matching the ineligible-recipient branch // above. Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => { tracing::info!( target: "audit", event = "magic_link.invitation_suppressed", reason = "resource_kind_unsupported", user_id = %recipient.id(), resource_kind = %resource.type_str(), "📭 magic-link invitation suppressed: {} resources aren't invitable via email", resource.type_str(), ); return Ok(()); } }; // Invitation tokens are cross-device by design (recipient has // no prior browser context with the server) — no challenge // cookie. Long TTL (default 24h) because recipients may not // check their email for a while. let token = MagicLinkToken::new( recipient.id(), chrono::Duration::hours(self.magic_link_cfg.invite_ttl_hours as i64), Some((kind, resource_id)), None, ); self.magic_link_repo.create(&token).await?; let link = format!( "{}/magic/v1/{}", self.public_base_url.trim_end_matches('/'), token.token(), ); let kind_key = match resource { Resource::Folder(_) => "server.magic_link.email.kind_folder", Resource::File(_) => "server.magic_link.email.kind_file", // Unreachable — the early-return above exits before we get // here for Drive / Calendar / AddressBook resources. The // arms exist only to satisfy exhaustiveness; if you find // any firing, the early-return was bypassed. Resource::Drive(_) | Resource::Calendar(_) | Resource::AddressBook(_) => { "server.magic_link.email.kind_folder" } }; // PR C: render in the recipient's preferred locale (set by UI // switcher, OIDC JIT claim, or inviter inheritance at row // creation). The bilingual partial appends English below when // the resolved locale isn't English, so a wrong guess still // produces a readable mail. let locale = self.locale_for(recipient); let kind_label = self.i18n_or(kind_key, &locale, &[]).await; let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string(); // Two display forms: `inviter` (short, no email) flows into the // subject line; `inviter_full` (with email decoration) flows // into the body. Templates pick whichever placeholder they // want — see static/locales/en.json `server.magic_link.email. // invitation.*` for the canonical references. let inviter_short = inviter.display_full(false); let inviter_full = inviter.display_full(true); let invite_args: Vec<(&str, &str)> = vec![ ("inviter", inviter_short.as_str()), ("inviter_full", inviter_full.as_str()), ("kind", &kind_label), ("link", &link), ("ttl_hours", &ttl_hours), ]; let subject = self .i18n_or( "server.magic_link.email.invitation.subject", &locale, &invite_args, ) .await; let text_body = self .render_bilingual( "server.magic_link.email.invitation.body", &locale, &invite_args, ) .await; let message = EmailMessage { to: recipient.email().to_string(), subject, text_body, html_body: None, }; // Synchronous send — see module docs for the enumeration-defense // trade-off. PR 12 promotes this to fire-and-forget when the // hardening pass lands. match self.email_sender.send(message).await { Ok(outcome) => { tracing::info!( target: "audit", event = "magic_link.invitation_sent", recipient_user_id = %recipient.id(), recipient_email = %recipient.email(), resource = ?resource, smtp_code = outcome.code, smtp_message = %outcome.message, ); Ok(()) } Err(e) => { // The grant already exists — log the SMTP failure but // don't propagate it as a fatal error, so the API client // still gets `201 Created` with the GrantDto. Recipient // can re-trigger via the future `POST /api/auth/magic-link/send` // endpoint once login-via-email lands. tracing::warn!( target: "audit", event = "magic_link.invitation_send_failed", recipient_user_id = %recipient.id(), recipient_email = %recipient.email(), error = %e.message, ); Ok(()) } } } /// Login-via-email flow (PR 10). Caller submits an email at /// `/login`; we look it up — **never lazy-create** here, that path /// is reserved for `resolve_or_create_recipient` — and if the /// matched user has no other login credential, mint a NULL-resource /// magic-link token and email a sign-in link. The redemption /// endpoint lands a NULL-resource token on `/#/sharedwithme`. /// /// Always returns `Ok(())` so the caller can emit a uniform /// response shape (`"If an account exists, a link will be sent."`) /// that doesn't reveal whether the email maps to an account. /// /// Audit log distinguishes three real outcomes — `sent`, /// `no_account`, `oidc_user`, `has_password` — so operators can see the truth /// while the API stays anti-enumeration-safe. A fourth outcome /// `send_failed` is logged at `warn` level when SMTP errors. /// /// `request_challenge` is the per-request random value the handler /// already set as the `oxicloud_magic_request` cookie on the /// originating browser. The service mirrors it into the token row; /// the redemption endpoint compares it against the inbound cookie /// to bind the magic-link to the device that requested it. /// Anti-enumeration: the handler passes the same challenge whether /// or not the user exists / is eligible — the token row is just /// not created in those branches, so nothing is leaked by the /// presence or absence of the cookie. pub async fn send_login_link( &self, raw_email: &str, request_challenge: &str, ) -> Result<(), DomainError> { let normalised = match normalize_email(raw_email) { Ok(n) => n, Err(e) => { // Malformed input is treated the same as "no account" // — uniform response, no oracle from validation errors. tracing::info!( target: "audit", event = "auth.magic_link_send", reason = "malformed_email", error = %e, "🔗 login-link suppressed: malformed email", ); return Ok(()); } }; let user = match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await { Ok(u) => u, Err(UserRepositoryError::NotFound(_)) => { tracing::info!( target: "audit", event = "auth.magic_link_send", reason = "no_account", email = %normalised, "🔗 login-link suppressed: no account for '{}'", normalised, ); return Ok(()); } Err(e) => return Err(DomainError::from(e)), }; if let Eligibility::Reject(reason) = magic_link_eligibility(&user, self.magic_link_cfg.open_to_password_users) { // Refuse the magic-link path for users who have a stronger // credential configured. OIDC is unconditional — the IdP is // the security boundary and we must not bypass any MFA it // enforces. Password is gated by `open_to_password_users`: // strict mode refuses (default — magic-link would weaken the // password to mailbox-strength); lenient mode allows. tracing::info!( target: "audit", event = "auth.magic_link_send", reason = reason, user_id = %user.id(), username = %user.display_for_audit(), email = %normalised, "🔗 login-link suppressed: '{}' rejected ({})", user.display_for_audit(), reason, ); return Ok(()); } if !user.is_active() { tracing::info!( target: "audit", event = "auth.magic_link_send", reason = "account_deactivated", user_id = %user.id(), username = %user.display_for_audit(), email = %normalised, "🔗 login-link suppressed: account deactivated for '{}'", user.display_for_audit(), ); return Ok(()); } // Mint a NULL-resource token bound to the requesting browser // via `request_challenge` (PR 22). Short TTL (default 10 min) // — the user just clicked the button, so a slow click is // almost certainly someone else with access to the inbox. let token = MagicLinkToken::new( user.id(), chrono::Duration::minutes(self.magic_link_cfg.login_ttl_minutes as i64), None, Some(request_challenge.to_string()), ); self.magic_link_repo.create(&token).await?; let link = format!( "{}/magic/v1/{}", self.public_base_url.trim_end_matches('/'), token.token(), ); // PR C: render in the user's preferred locale. Same bilingual // safety net as the invitation path — see `issue_invitation`. let locale = self.locale_for(&user); let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string(); let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)]; let subject = self .i18n_or( "server.magic_link.email.login.subject", &locale, &login_args, ) .await; let text_body = self .render_bilingual("server.magic_link.email.login.body", &locale, &login_args) .await; let message = EmailMessage { to: user.email().to_string(), subject, text_body, html_body: None, }; match self.email_sender.send(message).await { Ok(outcome) => { tracing::info!( target: "audit", event = "auth.magic_link_send", reason = "sent", user_id = %user.id(), username = %user.display_for_audit(), email = %normalised, smtp_code = outcome.code, smtp_message = %outcome.message, "🔗 login-link sent to '{}'", normalised, ); } Err(e) => { tracing::warn!( target: "audit", event = "auth.magic_link_send_failed", user_id = %user.id(), email = %normalised, error = %e.message, "🔗 login-link SMTP send failed for '{}'", normalised, ); } } Ok(()) } /// Resolve a translation, falling back to the literal key on any /// lookup error. Identical to the handler-side helper — kept inline /// here because the service layer can't pull in a UI util module /// without a circular dependency. async fn i18n_or(&self, key: &str, locale: &Locale, args: &[(&str, &str)]) -> String { self.i18n .translate_args(key, Some(locale.clone()), args) .await .unwrap_or_else(|_| key.to_string()) } /// Render an email body and, when the resolved locale isn't /// English, append the English translation below a divider. This /// is the "always readable" safety net: when locale inheritance /// guesses wrong (PR 9 invitation flow) or `preferred_locale` is /// stale, the recipient still has the English text to fall back /// on. English-locale recipients get a single block — the partial /// emits no divider in that case. async fn render_bilingual( &self, body_key: &str, locale: &Locale, args: &[(&str, &str)], ) -> String { let body = self.i18n_or(body_key, locale, args).await; let english_fallback = if locale.is_english() { None } else { // Resolve the English copy through the same interpolation // path so placeholder values are substituted identically. // PR-A's English-fallback inside the I18nService means the // resolution is reliable even if a translator hasn't // populated the English copy yet — defensive default in // both layers. Some(self.i18n_or(body_key, &Locale::english(), args).await) }; let divider = self .i18n_or( "server.magic_link.email.english_fallback_divider", locale, &[], ) .await; let template = BilingualEmailBody { body: body.clone(), divider, english_fallback, }; // `.render()` only fails on programmer error (template field // out of sync). Fall back to the raw body so we still send // *something* if the divider partial breaks. template.render().unwrap_or(body) } /// Look up the resend-recipient hint for a token whose redemption /// just failed. Returns `Some` exactly when: /// /// 1. the token row exists, /// 2. its status is `Expired` (TTL elapsed) or `Used` (already /// redeemed once — recipient may be re-clicking on a different /// device), and /// 3. the owning user account is still active. /// /// Returns `None` (no resend offered) for `Pending` tokens, unknown /// tokens, and deactivated accounts. The `None` branches deliberately /// look identical to the caller — anyone who can present a valid /// token already has its access semantics, so the only "oracle" /// surface is "did this token exist in some non-pending state", /// which is moot. pub async fn lookup_resend_recipient( &self, token: &str, ) -> Result, DomainError> { let Some(mlt) = self.magic_link_repo.find_by_token(token).await? else { return Ok(None); }; // Pending tokens are still redeemable — no reason to offer a // resend. The user should just click the original link again. if !matches!( mlt.status(), MagicLinkStatus::Expired | MagicLinkStatus::Used ) { return Ok(None); } let user = match UserRepository::get_user_by_id(&*self.user_storage, mlt.user_id()).await { Ok(u) => u, Err(UserRepositoryError::NotFound(_)) => return Ok(None), Err(e) => return Err(DomainError::from(e)), }; if !user.is_active() { return Ok(None); } let email = user.email().to_string(); let masked_email = mask_email(&email); Ok(Some(ResendRecipientHint { email, masked_email, })) } } /// Plain-text email body wrapper: emits the localized text, then — /// only when the resolved locale isn't English — a divider plus the /// English copy. Lives next to the service rather than under /// `templates/` because the partial is just a few lines and being /// co-located keeps the relationship between rendering code and /// template obvious. #[derive(Template)] #[template(path = "magic_link/email_body.txt")] struct BilingualEmailBody { body: String, divider: String, english_fallback: Option, } /// Hint surfaced by the 410-Gone page to offer a one-click "send me a /// fresh link" affordance to a recipient whose magic-link is no longer /// usable. Carries the recipient's email twice: the raw form (used by /// the resend handler to dispatch the new mail) and a masked form /// (rendered into the HTML page so the user can confirm the destination /// without the full address being plastered in the URL or address bar). #[derive(Debug, Clone)] pub struct ResendRecipientHint { pub email: String, pub masked_email: String, } /// Mask an email for display: keep the first character of the local /// part, then `…`, then the full domain. `alice@example.com` → /// `a…@example.com`. Short local parts (1 char) collapse to just /// `…@domain`. Malformed input (no `@`) is masked entirely as `…`. pub fn mask_email(email: &str) -> String { match email.rsplit_once('@') { Some((local, domain)) if !local.is_empty() => { let mut chars = local.chars(); let first = chars.next().unwrap_or('?'); format!("{first}…@{domain}") } Some((_, domain)) => format!("…@{domain}"), None => "…".to_string(), } } /// Lightweight conversion so the grant handler can derive a /// [`MagicLinkResourceKind`] from the already-parsed [`ResourceKind`] /// without re-importing match arms. impl From for MagicLinkResourceKind { fn from(kind: ResourceKind) -> Self { match kind { ResourceKind::Folder => Self::Folder, ResourceKind::File => Self::File, // Drives aren't a magic-link invite target in D0. The // grant DTO surface accepts drive resources, but the // grant_handler doesn't issue magic-links for them // (drive sharing lands in D2). Mapping Drive → Folder // gives a non-panicking fallback that would still emit a // valid token shape if the path were ever reached; the // runtime branches above suppress drive invitations // before reaching this conversion. ResourceKind::Drive => Self::Folder, } } } #[cfg(test)] mod tests { use super::*; use crate::domain::entities::user::{User, UserRole}; fn user(password: Option<&str>, oidc: Option<(&str, &str)>) -> User { let (provider, subject) = match oidc { Some((p, s)) => (Some(p.to_string()), Some(s.to_string())), None => (None, None), }; User::new( "test@example.com".to_string(), None, password.map(str::to_string), provider, subject, UserRole::User, 0, true, ) .expect("test user") } #[test] fn oidc_always_rejected_regardless_of_flag() { let u = user(None, Some(("google", "sub-123"))); assert_eq!( magic_link_eligibility(&u, false), Eligibility::Reject("oidc_user") ); assert_eq!( magic_link_eligibility(&u, true), Eligibility::Reject("oidc_user") ); } #[test] fn mask_email_keeps_one_char_of_local() { assert_eq!(mask_email("alice@example.com"), "a…@example.com"); assert_eq!(mask_email("very-long-name@example.com"), "v…@example.com"); } #[test] fn mask_email_handles_edge_cases() { // Single-char local part still leaks just the first char, by // design — same masking rule applies uniformly so the output // shape itself doesn't disclose local-part length. assert_eq!(mask_email("a@b.co"), "a…@b.co"); // Malformed (no `@`) is masked entirely. assert_eq!(mask_email("not-an-email"), "…"); // Pathological (starts with `@`) collapses the empty local. assert_eq!(mask_email("@example.com"), "…@example.com"); } #[test] fn password_user_strict_then_lenient() { let u = user(Some("$argon2id$..."), None); assert_eq!( magic_link_eligibility(&u, false), Eligibility::Reject("has_password") ); assert_eq!(magic_link_eligibility(&u, true), Eligibility::Allow); } #[test] fn no_credential_always_allowed() { let u = user(None, None); assert_eq!(magic_link_eligibility(&u, false), Eligibility::Allow); assert_eq!(magic_link_eligibility(&u, true), Eligibility::Allow); } #[test] fn oidc_dominates_password_when_both_set() { // Edge case: user has password AND OIDC linked. The ladder // checks OIDC first, so the rejection reason is "oidc_user" // (not "has_password"). The flag doesn't matter here either. let u = user(Some("hash"), Some(("google", "sub-123"))); assert_eq!( magic_link_eligibility(&u, false), Eligibility::Reject("oidc_user") ); assert_eq!( magic_link_eligibility(&u, true), Eligibility::Reject("oidc_user") ); } }