Skip to content

g1t/crates/contracts/src/accounts.rs

659 lines26,621 bytesCodeBlame
1//! A person's email addresses and the security of their account: identity's
2//! methods for them, and the rules they follow, kept pure so every caller
3//! applies the same ones.
4//!
5//! An account has up to [`MAX_EMAILS`] addresses. One is primary: account
6//! mail and password resets go there. A confirmed address belongs to one
7//! account; until someone confirms it, any account may have added it, and
8//! the first to confirm it keeps it. Sensitive changes need the person to
9//! have signed in within [`RECENT_AUTH_SECONDS`], or to give their password
10//! again ([`Reauth`]); a refusal for that is `FailureCode::ReauthRequired`.
11//!
12//! An account can turn on two-factor authentication: a code from an
13//! authenticator app (TOTP, RFC 6238), with recovery codes for when the app
14//! is lost. Signing in with a password then asks for a code as well.
15//!
16//! Workspaces can ask more of their members. [`WorkspacePolicy`] is where
17//! that goes: identity evaluates it wherever someone gains or uses access
18//! to a workspace. Today an owner can require two-factor authentication;
19//! the email rules are there for later.
20
21use serde::{Deserialize, Serialize};
22
23use crate::User;
24
25/// The most addresses one account may have, confirmed or not.
26pub const MAX_EMAILS: usize = 10;
27
28/// How long after signing in (or confirming the password) a person may make
29/// sensitive changes without being asked to prove it is them again.
30pub const RECENT_AUTH_SECONDS: u64 = 10 * 60;
31
32/// The least time between two confirmation emails to one address.
33pub const RESEND_SECONDS: u64 = 60;
34
35/// Where each person's private commit address lives:
36/// `<id suffix>+<username>@users.noreply.g1t.sh`.
37pub const NOREPLY_DOMAIN: &str = "users.noreply.g1t.sh";
38
39/// How many characters of the account id the noreply address carries. The
40/// end of an id is its random part, so a username alone never resolves.
41pub const NOREPLY_ID_CHARS: usize = 8;
42
43/// An address trimmed and lowercased, if it looks like one: something, an
44/// `@`, and a domain with a dot, at most 254 characters, no spaces.
45pub fn normalize_email(text: &str) -> Option<String> {
46 let email = text.trim().to_lowercase();
47 let well_formed = email.len() <= 254
48 && email.split_once('@').is_some_and(|(local, domain)| {
49 !local.is_empty() && !domain.contains('@') && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
50 })
51 && !email.contains(char::is_whitespace);
52 well_formed.then_some(email)
53}
54
55/// The person's noreply address, used for commits g1t makes for them when
56/// they keep their address private.
57pub fn noreply_address(user_id: &str, username: &str) -> String {
58 format!("{}+{}@{NOREPLY_DOMAIN}", id_suffix(user_id), username.to_lowercase())
59}
60
61/// The last [`NOREPLY_ID_CHARS`] characters of an account id, lowercased.
62pub fn id_suffix(user_id: &str) -> String {
63 let chars: Vec<char> = user_id.chars().collect();
64 let start = chars.len().saturating_sub(NOREPLY_ID_CHARS);
65 chars[start..].iter().collect::<String>().to_lowercase()
66}
67
68/// The id suffix and username a noreply address names, or `None` for any
69/// other address.
70pub fn parse_noreply(email: &str) -> Option<(String, String)> {
71 let email = email.trim().to_lowercase();
72 let local = email.strip_suffix(&format!("@{NOREPLY_DOMAIN}"))?;
73 let (suffix, username) = local.split_once('+')?;
74 (suffix.chars().count() == NOREPLY_ID_CHARS && !username.is_empty()).then(|| (suffix.to_owned(), username.to_owned()))
75}
76
77/// Whether a sign-in at `authenticated_at` (RFC 3339) is recent at `now`
78/// (RFC 3339), within `window_seconds`. Both are g1t's fixed format, which
79/// compares as text.
80pub fn is_recent(authenticated_at: Option<&str>, now_ms: u64, window_seconds: u64) -> bool {
81 let since = crate::time::rfc3339(now_ms.saturating_sub(window_seconds * 1000));
82 authenticated_at.is_some_and(|at| at >= since.as_str())
83}
84
85/// One address, as the rules about removing and choosing addresses see it.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct EmailState {
88 pub email: String,
89 pub verified: bool,
90 pub primary: bool,
91}
92
93/// Why `email` cannot be removed from an account with `all`, or `None`.
94pub fn removal_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
95 let Some(target) = all.iter().find(|state| state.email == email) else {
96 return Some("That address is not on your account.");
97 };
98 if target.primary {
99 return Some("That is your primary address. Make another confirmed address primary first.");
100 }
101 let confirmed = all.iter().filter(|state| state.verified).count();
102 if target.verified && confirmed <= 1 {
103 return Some("That is your only confirmed address. Add and confirm another first.");
104 }
105 None
106}
107
108/// Why `email` cannot be made primary, or `None`.
109pub fn primary_refusal(all: &[EmailState], email: &str) -> Option<&'static str> {
110 match all.iter().find(|state| state.email == email) {
111 None => Some("That address is not on your account."),
112 Some(state) if !state.verified => Some("Confirm that address before making it primary."),
113 Some(_) => None,
114 }
115}
116
117/// Proof that the person making a sensitive change is the account's owner,
118/// now. Either is enough: the session they are using, if they signed in to
119/// it within [`RECENT_AUTH_SECONDS`], or their password. A correct password
120/// also renews the session's sign-in time, so they are not asked again
121/// straight away.
122#[derive(Clone, Debug, Default, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct Reauth {
125 #[serde(default)]
126 pub session_token: Option<String>,
127 #[serde(default)]
128 pub password: Option<String>,
129 /// Who is asking, such as the visitor's IP address, so wrong passwords
130 /// are counted against it too.
131 #[serde(default)]
132 pub client: Option<String>,
133}
134
135/// One of a person's addresses, as they see it.
136#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct AccountEmail {
139 /// As typed when it was added.
140 pub email: String,
141 pub verified: bool,
142 pub primary: bool,
143 /// Gets security notices as well as the primary.
144 pub backup: bool,
145 /// RFC 3339.
146 pub created_at: String,
147 /// RFC 3339.
148 pub verified_at: Option<String>,
149}
150
151/// A person's addresses and what they do with them. `list_emails` (takes
152/// `UserArgs`) returns `Outcome<AccountEmails>`, and so does every change.
153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct AccountEmails {
156 /// The primary first, then confirmed addresses, then the rest, oldest
157 /// first within each.
158 pub emails: Vec<AccountEmail>,
159 /// Commits g1t makes for the person use `noreply`, not the primary.
160 pub private_email: bool,
161 /// Pushes of commits that carry one of the person's addresses are
162 /// refused while `private_email` is on.
163 pub block_private_pushes: bool,
164 /// `<id suffix>+<username>@users.noreply.g1t.sh`.
165 pub noreply: String,
166 /// The address commits g1t makes for the person carry now.
167 pub commit_email: String,
168 /// [`MAX_EMAILS`].
169 pub limit: u32,
170}
171
172/// `add_email`: adds an address and emails it a confirmation link; adding
173/// one already on the account and unconfirmed sends the link again.
174/// `remove_email`: removes one, never the primary nor the last confirmed
175/// address. Both need [`Reauth`] and tell every confirmed address.
176/// `resend_email_verification` sends the link again, at most once every
177/// [`RESEND_SECONDS`], and needs no reauth. People only: never an agent's
178/// or a workspace's token.
179#[derive(Debug, Serialize, Deserialize)]
180pub struct AccountEmailArgs {
181 pub user: User,
182 pub email: String,
183 #[serde(default)]
184 pub reauth: Reauth,
185}
186
187/// `update_email_settings`: each field given is changed. `primary` must be
188/// a confirmed address. `backup` is a confirmed address to get security
189/// notices too, or empty for the primary only. Changing either needs
190/// [`Reauth`]; the privacy switches do not. Returns `Outcome<AccountEmails>`.
191#[derive(Debug, Default, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct EmailSettingsArgs {
194 pub user: User,
195 #[serde(default)]
196 pub primary: Option<String>,
197 #[serde(default)]
198 pub backup: Option<String>,
199 #[serde(default)]
200 pub private_email: Option<bool>,
201 #[serde(default)]
202 pub block_private_pushes: Option<bool>,
203 #[serde(default)]
204 pub reauth: Reauth,
205}
206
207/// `reauthenticate`: the person typed their password again for the session
208/// they are using; sensitive changes need no more proof for
209/// [`RECENT_AUTH_SECONDS`]. Returns `Outcome<bool>`.
210#[derive(Debug, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct ReauthenticateArgs {
213 pub session_token: String,
214 pub password: String,
215 #[serde(default)]
216 pub client: Option<String>,
217}
218
219/// `email_owners`: who wrote commits, by their author addresses. Matches
220/// confirmed addresses and noreply addresses only, never an unconfirmed
221/// one. At most 200 addresses. Returns a map from each address that
222/// matched, lowercased, to its owner.
223#[derive(Debug, Serialize, Deserialize)]
224pub struct EmailOwnersArgs {
225 pub emails: Vec<String>,
226}
227
228/// The account an address belongs to.
229#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
230pub struct EmailOwner {
231 pub id: String,
232 pub username: String,
233 pub avatar: Option<String>,
234}
235
236/// `commit_identity`: the name and address to put on a commit g1t makes for
237/// a person (a merge, a web edit, catching a branch up). Their noreply
238/// address while they keep their address private, otherwise their primary.
239/// Returns `Option<CommitIdentity>`; null for an unknown account.
240#[derive(Debug, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct CommitIdentityArgs {
243 pub user_id: String,
244}
245
246#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
247pub struct CommitIdentity {
248 pub name: String,
249 pub email: String,
250}
251
252/// `push_email_guard` (takes `CommitIdentityArgs`): what a push by this
253/// person must not publish. Returns `Option<PushEmailGuard>`: null unless
254/// they keep their address private and block pushes that expose it.
255#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
256pub struct PushEmailGuard {
257 /// Their confirmed addresses, lowercased.
258 pub emails: Vec<String>,
259 /// The address to commit with instead.
260 pub noreply: String,
261}
262
263impl PushEmailGuard {
264 /// Whether a commit carrying `email` would publish one of the
265 /// person's addresses.
266 pub fn exposes(&self, email: &str) -> bool {
267 let email = email.trim().to_lowercase();
268 !email.is_empty() && self.emails.contains(&email)
269 }
270}
271
272/// An address with all but the first letter of its local part hidden:
273/// `s***@gmail.com`.
274pub fn mask_email(email: &str) -> String {
275 match email.split_once('@') {
276 Some((local, domain)) => {
277 let first: String = local.chars().take(1).collect();
278 format!("{first}***@{domain}")
279 }
280 None => "***".to_owned(),
281 }
282}
283
284/// Something that happened to an account's security. `security_log` (takes
285/// `UserArgs`) returns the newest [`SECURITY_LOG_LIMIT`], newest first.
286#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct SecurityEvent {
289 /// `email_added`, `email_verified`, `email_removed`,
290 /// `primary_email_changed`, `backup_email_changed`,
291 /// `email_privacy_changed`, `password_changed`, `two_factor_enabled`,
292 /// `two_factor_disabled`, `recovery_codes_regenerated`,
293 /// `recovery_code_used`, `token_created`, `token_deleted`,
294 /// `token_rescoped`, `ssh_key_added`, `ssh_key_removed`,
295 /// `oauth_grant_created`, `oauth_grant_revoked` or
296 /// `oauth_grant_rescoped`.
297 pub kind: String,
298 /// The address concerned, or what changed.
299 pub detail: Option<String>,
300 /// Whether g1t staff made the change.
301 pub by_staff: bool,
302 /// Why staff made it.
303 pub reason: Option<String>,
304 /// The staff member, by email. Only in staff views.
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub staff: Option<String>,
307 /// RFC 3339.
308 pub created_at: String,
309}
310
311/// How many entries `security_log` returns.
312pub const SECURITY_LOG_LIMIT: usize = 50;
313
314// --- Two-factor authentication ---
315
316/// How long one TOTP code lasts, in seconds (RFC 6238's default).
317pub const TOTP_STEP_SECONDS: u64 = 30;
318/// How many digits a code has.
319pub const TOTP_DIGITS: u32 = 6;
320/// How many steps either side of now a code is accepted from, for clocks
321/// that are a little off: one, so a code works for up to 90 seconds.
322pub const TOTP_SKEW_STEPS: u64 = 1;
323/// How many recovery codes an account gets.
324pub const RECOVERY_CODES: usize = 10;
325/// How long a sign-in waits for its code, in seconds.
326pub const TWO_FACTOR_CHALLENGE_SECONDS: u64 = 10 * 60;
327/// How many wrong codes one sign-in may have before it must start again.
328pub const TWO_FACTOR_ATTEMPTS: u32 = 5;
329/// The issuer authenticator apps show beside the account.
330pub const TOTP_ISSUER: &str = "g1t";
331
332/// Where an account's two-factor authentication stands.
333/// `two_factor_status` (takes `UserArgs`) returns `Outcome<TwoFactorStatus>`.
334#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
335pub struct TwoFactorStatus {
336 pub enabled: bool,
337 /// RFC 3339.
338 pub enabled_at: Option<String>,
339 /// Recovery codes not used yet.
340 pub recovery_codes_left: u32,
341 /// The workspaces the person belongs to that require it.
342 pub required_by: Vec<String>,
343}
344
345/// What an authenticator app needs: the secret in base32, and the same as
346/// an `otpauth://` address for a QR code.
347#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
348pub struct TwoFactorSetup {
349 pub secret: String,
350 pub uri: String,
351}
352
353/// Single-use recovery codes, shown once.
354#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
355pub struct RecoveryCodes {
356 pub codes: Vec<String>,
357}
358
359/// `two_factor_start`: begins turning it on, replacing any enrolment in
360/// progress. Needs [`Reauth`]. Refused while it is on. Returns
361/// `Outcome<TwoFactorSetup>`.
362///
363/// `two_factor_recovery_codes`: makes new recovery codes, replacing the
364/// old ones. Needs [`Reauth`] and two-factor on. Returns
365/// `Outcome<RecoveryCodes>`.
366#[derive(Debug, Serialize, Deserialize)]
367pub struct TwoFactorArgs {
368 pub user: User,
369 #[serde(default)]
370 pub reauth: Reauth,
371}
372
373/// `two_factor_enable`: a code from the app confirms the enrolment, and
374/// two-factor is on; returns the recovery codes, shown once.
375/// `two_factor_disable`: turns it off; needs a code (or a recovery code)
376/// as well as [`Reauth`]. Refused for an owner of a workspace that
377/// requires it. Both return `Outcome<...>`: `RecoveryCodes` and `bool`.
378#[derive(Debug, Serialize, Deserialize)]
379pub struct TwoFactorCodeArgs {
380 pub user: User,
381 pub code: String,
382 #[serde(default)]
383 pub reauth: Reauth,
384}
385
386/// `two_factor_sign_in`: the second step of signing in. `challenge` is
387/// what `sign_in` returned as `SignedIn::two_factor_challenge`; `code` is a
388/// code from the app or a recovery code. Returns `Outcome<SignedIn>`, with
389/// a session.
390#[derive(Debug, Serialize, Deserialize)]
391pub struct TwoFactorSignInArgs {
392 pub challenge: String,
393 pub code: String,
394 #[serde(default)]
395 pub client: Option<String>,
396}
397
398/// The `otpauth://` address for a secret, as authenticator apps read it
399/// from a QR code.
400pub fn otpauth_uri(secret_base32: &str, username: &str) -> String {
401 let label: String = format!("{TOTP_ISSUER}:{username}")
402 .chars()
403 .map(|c| if c.is_ascii_alphanumeric() || "-._~:".contains(c) { c.to_string() } else { format!("%{:02X}", c as u32) })
404 .collect();
405 format!(
406 "otpauth://totp/{label}?secret={secret_base32}&issuer={TOTP_ISSUER}&algorithm=SHA1&digits={TOTP_DIGITS}&period={TOTP_STEP_SECONDS}"
407 )
408}
409
410/// A code as typed, tidied: spaces and hyphens taken out, lowercased.
411pub fn tidy_code(code: &str) -> String {
412 code.chars().filter(|c| !c.is_whitespace() && *c != '-').collect::<String>().to_lowercase()
413}
414
415/// Whether a tidied code is shaped like an app's: six digits.
416pub fn is_totp_shaped(code: &str) -> bool {
417 code.len() == TOTP_DIGITS as usize && code.chars().all(|c| c.is_ascii_digit())
418}
419
420// --- Staff ---
421
422/// `admin_user` (takes `UsernameArgs`): one account's addresses and
423/// security log, for staff. Returns `Option<AdminUser>`.
424#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub struct AdminUser {
427 pub id: String,
428 pub username: String,
429 /// RFC 3339.
430 pub created_at: String,
431 pub emails: Vec<AccountEmail>,
432 pub private_email: bool,
433 pub log: Vec<SecurityEvent>,
434}
435
436/// `admin_remove_email`: staff remove an address from an account, such as
437/// an unconfirmed one someone else needs or a compromised one. Never the
438/// last confirmed address; removing the primary makes the oldest other
439/// confirmed address primary. Recorded in the person's security log with
440/// the reason, and the person is told. Returns `Outcome<AdminUser>`.
441#[derive(Debug, Serialize, Deserialize)]
442pub struct AdminRemoveEmailArgs {
443 pub username: String,
444 pub email: String,
445 pub reason: String,
446 /// The staff member, by email.
447 pub staff: String,
448}
449
450// --- Workspace policy ---
451
452/// What a workspace asks of its members' accounts. Nothing, today, for
453/// every workspace ([`WorkspacePolicy::default`]); identity already checks
454/// it wherever someone joins a workspace or uses access to one, so asking
455/// for more is a matter of storing it.
456#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
457#[serde(rename_all = "camelCase")]
458pub struct WorkspacePolicy {
459 /// Members need a confirmed address at one of these domains. Empty:
460 /// any domain.
461 #[serde(default)]
462 pub allowed_email_domains: Vec<String>,
463 /// Members need a confirmed address at all.
464 #[serde(default)]
465 pub require_verified_email: bool,
466 /// Members need a second factor on their account.
467 #[serde(default)]
468 pub require_two_factor: bool,
469}
470
471/// What a policy can ask about an account.
472#[derive(Clone, Debug, Default, PartialEq, Eq)]
473pub struct SecurityFacts {
474 /// Lowercased.
475 pub verified_emails: Vec<String>,
476 pub two_factor: bool,
477}
478
479/// What an account lacks to meet a workspace's policy.
480#[derive(Clone, Debug, PartialEq, Eq)]
481pub enum PolicyGap {
482 VerifiedEmail,
483 EmailDomain(Vec<String>),
484 TwoFactor,
485}
486
487impl PolicyGap {
488 /// Its name: `verified_email`, `email_domain` or `two_factor`.
489 pub fn as_str(&self) -> &'static str {
490 match self {
491 PolicyGap::VerifiedEmail => "verified_email",
492 PolicyGap::EmailDomain(_) => "email_domain",
493 PolicyGap::TwoFactor => "two_factor",
494 }
495 }
496
497 /// What to tell the person, for a workspace named `slug`.
498 pub fn message(&self, slug: &str) -> String {
499 match self {
500 PolicyGap::VerifiedEmail => format!("{slug} needs members to have a confirmed email address."),
501 PolicyGap::EmailDomain(domains) => format!(
502 "{slug} needs members to have a confirmed address at {}. Add one in your account settings.",
503 domains.join(" or ")
504 ),
505 PolicyGap::TwoFactor => format!("{slug} requires two-factor authentication. Turn it on in your account's security settings to use it again."),
506 }
507 }
508}
509
510impl WorkspacePolicy {
511 /// Whether the policy asks for anything, so callers can skip gathering
512 /// [`SecurityFacts`] when it does not.
513 pub fn asks_nothing(&self) -> bool {
514 self.allowed_email_domains.is_empty() && !self.require_verified_email && !self.require_two_factor
515 }
516
517 /// Everything the account lacks, or an empty list when it meets the
518 /// policy.
519 pub fn gaps(&self, facts: &SecurityFacts) -> Vec<PolicyGap> {
520 let mut gaps = Vec::new();
521 if self.require_verified_email && facts.verified_emails.is_empty() {
522 gaps.push(PolicyGap::VerifiedEmail);
523 }
524 if !self.allowed_email_domains.is_empty() {
525 let allowed: Vec<String> = self.allowed_email_domains.iter().map(|domain| domain.trim().trim_start_matches('@').to_lowercase()).collect();
526 let has = facts.verified_emails.iter().any(|email| {
527 email
528 .rsplit_once('@')
529 .is_some_and(|(_, domain)| allowed.iter().any(|allowed| domain == allowed))
530 });
531 if !has {
532 gaps.push(PolicyGap::EmailDomain(allowed));
533 }
534 }
535 if self.require_two_factor && !facts.two_factor {
536 gaps.push(PolicyGap::TwoFactor);
537 }
538 gaps
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn a_push_guard_matches_the_persons_own_addresses_and_masks_them() {
548 let guard = PushEmailGuard { emails: vec!["sam@gmail.com".into()], noreply: "abc+sam@users.noreply.g1t.sh".into() };
549 assert!(guard.exposes(" Sam@Gmail.com"));
550 assert!(!guard.exposes("abc+sam@users.noreply.g1t.sh"));
551 assert!(!guard.exposes("someone@gmail.com"));
552 assert!(!guard.exposes(""));
553 assert_eq!(mask_email("sam@gmail.com"), "s***@gmail.com");
554 assert_eq!(mask_email("nope"), "***");
555 }
556
557 fn state(email: &str, verified: bool, primary: bool) -> EmailState {
558 EmailState { email: email.into(), verified, primary }
559 }
560
561 #[test]
562 fn addresses_are_trimmed_lowercased_and_checked() {
563 assert_eq!(normalize_email(" Ada@Example.COM "), Some("ada@example.com".into()));
564 assert_eq!(normalize_email("ada+g1t@mail.example.co.uk"), Some("ada+g1t@mail.example.co.uk".into()));
565 for bad in ["", "ada", "@example.com", "ada@example", "ada@@example.com", "a da@example.com", "ada@.com", "ada@example."] {
566 assert_eq!(normalize_email(bad), None, "{bad}");
567 }
568 assert_eq!(normalize_email(&format!("{}@example.com", "a".repeat(250))), None);
569 }
570
571 #[test]
572 fn the_noreply_address_carries_the_end_of_the_id_and_the_username() {
573 let address = noreply_address("usr_01j9zq4m8x7k2v5n3b6c1d0efg", "Ada");
574 assert_eq!(address, "6c1d0efg+ada@users.noreply.g1t.sh");
575 assert_eq!(parse_noreply(&address), Some(("6c1d0efg".into(), "ada".into())));
576 assert_eq!(parse_noreply("6C1D0EFG+Ada@Users.Noreply.G1T.sh"), Some(("6c1d0efg".into(), "ada".into())));
577 assert_eq!(parse_noreply("ada@example.com"), None);
578 assert_eq!(parse_noreply("short+ada@users.noreply.g1t.sh"), None);
579 assert_eq!(parse_noreply("6c1d0efg@users.noreply.g1t.sh"), None);
580 assert_eq!(parse_noreply("6c1d0efg+@users.noreply.g1t.sh"), None);
581 assert_eq!(id_suffix("usr_x"), "usr_x");
582 }
583
584 #[test]
585 fn a_sign_in_is_recent_for_ten_minutes() {
586 let now = 1_800_000_000_000;
587 let at = |ms_ago: u64| crate::time::rfc3339(now - ms_ago);
588 assert!(is_recent(Some(&at(0)), now, RECENT_AUTH_SECONDS));
589 assert!(is_recent(Some(&at(9 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
590 assert!(is_recent(Some(&at(10 * 60 * 1000)), now, RECENT_AUTH_SECONDS));
591 assert!(!is_recent(Some(&at(10 * 60 * 1000 + 1)), now, RECENT_AUTH_SECONDS));
592 assert!(!is_recent(None, now, RECENT_AUTH_SECONDS));
593 }
594
595 #[test]
596 fn neither_the_primary_nor_the_last_confirmed_address_can_be_removed() {
597 let all = [state("a@x.io", true, true), state("b@x.io", true, false), state("c@x.io", false, false)];
598 assert!(removal_refusal(&all, "a@x.io").unwrap().contains("primary"));
599 assert_eq!(removal_refusal(&all, "b@x.io"), None);
600 assert_eq!(removal_refusal(&all, "c@x.io"), None);
601 assert!(removal_refusal(&all, "d@x.io").is_some());
602 // An unconfirmed primary (a new account) stays; so does the only
603 // confirmed address, primary or not.
604 let lone = [state("a@x.io", false, true), state("b@x.io", true, false)];
605 assert!(removal_refusal(&lone, "b@x.io").unwrap().contains("only confirmed"));
606 }
607
608 #[test]
609 fn only_a_confirmed_address_can_be_primary() {
610 let all = [state("a@x.io", true, true), state("b@x.io", false, false)];
611 assert_eq!(primary_refusal(&all, "a@x.io"), None);
612 assert!(primary_refusal(&all, "b@x.io").unwrap().contains("Confirm"));
613 assert!(primary_refusal(&all, "z@x.io").is_some());
614 }
615
616 #[test]
617 fn the_otpauth_address_names_the_account_and_issuer() {
618 let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "ada lovelace");
619 assert_eq!(
620 uri,
621 "otpauth://totp/g1t:ada%20lovelace?secret=JBSWY3DPEHPK3PXP&issuer=g1t&algorithm=SHA1&digits=6&period=30"
622 );
623 }
624
625 #[test]
626 fn codes_are_tidied_before_they_are_checked() {
627 assert_eq!(tidy_code(" 123 456 "), "123456");
628 assert!(is_totp_shaped(&tidy_code("123-456")));
629 assert!(!is_totp_shaped("12345"));
630 assert!(!is_totp_shaped("abcdef"));
631 assert_eq!(tidy_code("ABCD-EFGH-IJ"), "abcdefghij");
632 }
633
634 #[test]
635 fn the_default_policy_asks_nothing_and_any_account_meets_it() {
636 let policy = WorkspacePolicy::default();
637 assert!(policy.asks_nothing());
638 assert!(policy.gaps(&SecurityFacts::default()).is_empty());
639 }
640
641 #[test]
642 fn a_policy_names_everything_an_account_lacks() {
643 let policy = WorkspacePolicy {
644 allowed_email_domains: vec!["@Acme.com".into()],
645 require_verified_email: true,
646 require_two_factor: true,
647 };
648 assert!(!policy.asks_nothing());
649 assert_eq!(
650 policy.gaps(&SecurityFacts::default()),
651 vec![PolicyGap::VerifiedEmail, PolicyGap::EmailDomain(vec!["acme.com".into()]), PolicyGap::TwoFactor]
652 );
653 let member = SecurityFacts { verified_emails: vec!["ada@gmail.com".into(), "ada@acme.com".into()], two_factor: true };
654 assert!(policy.gaps(&member).is_empty());
655 let lookalike = SecurityFacts { verified_emails: vec!["ada@notacme.com".into()], two_factor: true };
656 assert_eq!(policy.gaps(&lookalike), vec![PolicyGap::EmailDomain(vec!["acme.com".into()])]);
657 assert!(PolicyGap::EmailDomain(vec!["acme.com".into()]).message("acme").contains("acme.com"));
658 }
659}