Skip to main content

acme_proxy/sqlite/
admin_user.rs

1use serde_json::Value;
2use sqlx::Row;
3use sqlx::sqlite::SqliteRow;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::sqlite::db::Database;
8use crate::sqlite::nonce::now_secs;
9use crate::sqlite::order::rfc3339;
10
11/// An operator of the web admin interface.
12///
13/// Not an ACME concept and never joined to one: an [`AdminUser`] is a person
14/// with a password, an `accounts` row is a client key. There is no `profile`
15/// column -- an admin user sees every endpoint this process serves.
16///
17/// ## Methods
18///
19/// - `create`: persist a new operator, `active`
20/// - `find_by_id` / `find_by_username`: lookup (the latter is the login path)
21/// - `list_all`: every operator, oldest first
22/// - `set_password_hash` / `set_status` / `mark_logged_in`: in-place updates
23/// - `set_totp_pending` / `confirm_totp` / `clear_totp` / `claim_totp_step`:
24///   the second factor's lifecycle, and RFC 6238 §5.2's replay guard
25/// - `delete`: remove, cascading to the operator's sessions and recovery codes
26/// - `to_json`: admin-facing rendering (never the password hash, never a secret)
27#[derive(Debug, Clone)]
28pub struct AdminUser {
29    pub id: Uuid,
30    /// Always lowercase: [`AdminUser::create`] normalizes before writing, so
31    /// `Alice` and `alice` cannot become two logins that read as one.
32    pub username: String,
33    /// The encoded KDF output -- see `crate::admin::password`. Never rendered.
34    pub password_hash: String,
35    pub status: String,
36    /// Set once the owner has proven a code against a pending enrolment.
37    /// `None` means no second factor is configured.
38    pub totp_secret: Option<Vec<u8>>,
39    /// An enrolment begun but not yet confirmed. Not a usable second factor.
40    pub totp_pending_secret: Option<Vec<u8>>,
41    /// The last TOTP time step accepted, so a code cannot be replayed inside
42    /// its own window.
43    pub totp_last_step: Option<i64>,
44    pub created_at: i64,
45    pub updated_at: i64,
46    pub last_login_at: Option<i64>,
47}
48
49/// Every column of `admin_users`, in one place: each read must select the same set
50/// or `from_row` fails on whichever forgot one.
51///
52/// A `macro_rules!` rather than a `const` so the expansion is a string
53/// *literal*, which is what `sqlx::query`'s `SqlSafeStr` bound requires.
54macro_rules! columns {
55    () => {
56        "id, username, password_hash, status, totp_secret, totp_pending_secret, \
57         totp_last_step, created_at, updated_at, last_login_at"
58    };
59}
60
61impl AdminUser {
62    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
63        Ok(AdminUser {
64            id: row.try_get("id")?,
65            username: row.try_get("username")?,
66            password_hash: row.try_get("password_hash")?,
67            status: row.try_get("status")?,
68            totp_secret: row.try_get("totp_secret")?,
69            totp_pending_secret: row.try_get("totp_pending_secret")?,
70            totp_last_step: row.try_get("totp_last_step")?,
71            created_at: row.try_get("created_at")?,
72            updated_at: row.try_get("updated_at")?,
73            last_login_at: row.try_get("last_login_at")?,
74        })
75    }
76
77    /// Persists a new operator, `active`. `username` is lowercased and trimmed
78    /// here rather than at the call sites, so every path -- the CLI, a future
79    /// API -- stores the same thing.
80    ///
81    /// `password_hash` is already encoded by `crate::admin::password`: this
82    /// layer never sees a plaintext password and cannot hash one.
83    ///
84    /// A duplicate username surfaces as the UNIQUE violation it is; the caller
85    /// (`admin::users::create_user`) checks first and reports it in words.
86    pub async fn create(
87        username: &str,
88        password_hash: &str,
89        database: &Database,
90    ) -> Result<AdminUser, sqlx::Error> {
91        let now = now_secs();
92        let user = AdminUser {
93            id: crate::sqlite::id::mint(),
94            username: username.trim().to_lowercase(),
95            password_hash: password_hash.to_string(),
96            status: "active".to_string(),
97            totp_secret: None,
98            totp_pending_secret: None,
99            totp_last_step: None,
100            created_at: now,
101            updated_at: now,
102            last_login_at: None,
103        };
104
105        debug!(event = "db_admin_user_create_started", outcome = "progress", username = %user.username);
106        sqlx::query(
107            "INSERT INTO admin_users (id, username, password_hash, status, created_at, updated_at) \
108             VALUES (?, ?, ?, ?, ?, ?);",
109        )
110        .bind(user.id)
111        .bind(&user.username)
112        .bind(&user.password_hash)
113        .bind(&user.status)
114        .bind(user.created_at)
115        .bind(user.updated_at)
116        .execute(&database.pool)
117        .await?;
118
119        info!(event = "db_admin_user_created", outcome = "success", user_id = %user.id, username = %user.username);
120        Ok(user)
121    }
122
123    pub async fn find_by_id(
124        id: Uuid,
125        database: &Database,
126    ) -> Result<Option<AdminUser>, sqlx::Error> {
127        debug!(event = "db_admin_user_find_by_id_started", outcome = "progress", id = ?id);
128        let row = sqlx::query(concat!(
129            "SELECT ",
130            columns!(),
131            " FROM admin_users WHERE id = ?;"
132        ))
133        .bind(id)
134        .fetch_optional(&database.pool)
135        .await?;
136
137        row.map(AdminUser::from_row).transpose()
138    }
139
140    /// The login path. Lowercases the argument for the same reason
141    /// [`AdminUser::create`] does -- a login typed `Alice` must find `alice`.
142    pub async fn find_by_username(
143        username: &str,
144        database: &Database,
145    ) -> Result<Option<AdminUser>, sqlx::Error> {
146        debug!(
147            event = "db_admin_user_find_by_username_started",
148            outcome = "progress"
149        );
150        let row = sqlx::query(concat!(
151            "SELECT ",
152            columns!(),
153            " FROM admin_users WHERE username = ?;"
154        ))
155        .bind(username.trim().to_lowercase())
156        .fetch_optional(&database.pool)
157        .await?;
158
159        row.map(AdminUser::from_row).transpose()
160    }
161
162    /// Every operator, oldest first.
163    ///
164    /// A **scan**, not a listing: its one caller is
165    /// `admin::mfa::operators_without_a_factor`, which counts the operators
166    /// with no confirmed factor for the `admin.require_mfa` startup warning.
167    /// Nothing renders it, which is why it can sit beside [`AdminUser::search`]
168    /// without being the second listing the paging pass deleted
169    /// `Account::list_all` for -- an order nothing displays cannot disagree
170    /// with the paged one.
171    pub async fn list_all(database: &Database) -> Result<Vec<AdminUser>, sqlx::Error> {
172        debug!(
173            event = "db_admin_user_list_all_started",
174            outcome = "progress"
175        );
176        let rows = sqlx::query(concat!(
177            "SELECT ",
178            columns!(),
179            " FROM admin_users ORDER BY created_at ASC, id ASC;"
180        ))
181        .fetch_all(&database.pool)
182        .await?;
183
184        rows.into_iter().map(AdminUser::from_row).collect()
185    }
186
187    /// One page of `admin user list`, plus the total the table holds unpaged.
188    ///
189    /// **Oldest first**, and the one listing in the binary that is: every other
190    /// paged listing puts the newest row on top, but the bootstrap operator --
191    /// the one created before the panel could be signed in to at all -- is
192    /// precisely the row whose position should not move as colleagues are
193    /// added. `id` breaks the `created_at` tie for `Eab::search`'s reason:
194    /// `created_at` is a whole second, and operators are created in one go.
195    pub async fn search(
196        limit: i64,
197        offset: i64,
198        database: &Database,
199    ) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
200        debug!(
201            event = "db_admin_user_search_started",
202            outcome = "progress",
203            limit = limit,
204            offset = offset
205        );
206        let rows = sqlx::query(concat!(
207            "SELECT ",
208            columns!(),
209            " FROM admin_users ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?;"
210        ))
211        .bind(limit)
212        .bind(offset)
213        .fetch_all(&database.pool)
214        .await?;
215        let total: i64 = sqlx::query("SELECT COUNT(*) FROM admin_users;")
216            .fetch_one(&database.pool)
217            .await?
218            .try_get(0)?;
219
220        let users = rows
221            .into_iter()
222            .map(AdminUser::from_row)
223            .collect::<Result<_, _>>()?;
224        Ok((users, total))
225    }
226
227    /// Replaces the stored hash. Callers are responsible for invalidating the
228    /// owner's sessions -- `admin::users::set_password` does, and a password
229    /// change that left them alive would be a change in name only.
230    pub async fn set_password_hash(
231        &mut self,
232        password_hash: &str,
233        database: &Database,
234    ) -> Result<(), sqlx::Error> {
235        let now = now_secs();
236        sqlx::query("UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?;")
237            .bind(password_hash)
238            .bind(now)
239            .bind(self.id)
240            .execute(&database.pool)
241            .await?;
242
243        self.password_hash = password_hash.to_string();
244        self.updated_at = now;
245        info!(event = "db_admin_user_password_changed", outcome = "success", user_id = %self.id, username = %self.username);
246        Ok(())
247    }
248
249    /// Moves between `active` and `disabled`. A disabled operator cannot log
250    /// in, and an existing session of theirs is refused on its next use --
251    /// the session rows are left for the reaper rather than deleted here, so
252    /// re-enabling is a single UPDATE either way.
253    pub async fn set_status(
254        &mut self,
255        status: &str,
256        database: &Database,
257    ) -> Result<(), sqlx::Error> {
258        let now = now_secs();
259        sqlx::query("UPDATE admin_users SET status = ?, updated_at = ? WHERE id = ?;")
260            .bind(status)
261            .bind(now)
262            .bind(self.id)
263            .execute(&database.pool)
264            .await?;
265
266        self.status = status.to_string();
267        self.updated_at = now;
268        info!(event = "db_admin_user_status_changed", outcome = "success", user_id = %self.id, username = %self.username, status = %status);
269        Ok(())
270    }
271
272    /// Stores an enrolment the owner has not yet proven a code against.
273    ///
274    /// Not a usable second factor: [`AdminUser::has_totp`] stays `false` until
275    /// [`AdminUser::confirm_totp`] moves it across, which is what stops an
276    /// abandoned enrolment from locking its own owner out.
277    pub async fn set_totp_pending(
278        &mut self,
279        secret: &[u8],
280        database: &Database,
281    ) -> Result<(), sqlx::Error> {
282        let now = now_secs();
283        sqlx::query("UPDATE admin_users SET totp_pending_secret = ?, updated_at = ? WHERE id = ?;")
284            .bind(secret)
285            .bind(now)
286            .bind(self.id)
287            .execute(&database.pool)
288            .await?;
289
290        self.totp_pending_secret = Some(secret.to_vec());
291        self.updated_at = now;
292        info!(event = "db_admin_totp_enrolment_started", outcome = "progress", user_id = %self.id, username = %self.username);
293        Ok(())
294    }
295
296    /// Promotes the pending secret to the real one.
297    ///
298    /// One statement, deliberately: a half-applied enrolment would leave the
299    /// operator believing they have a factor that nothing checks, or holding a
300    /// pending secret alongside a live one. `totp_last_step` is cleared with
301    /// them -- the replay guard belongs to the secret it was recorded against.
302    ///
303    /// A no-op when nothing is pending, so a double-submit cannot clear a live
304    /// factor.
305    pub async fn confirm_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
306        let Some(pending) = self.totp_pending_secret.clone() else {
307            return Ok(());
308        };
309
310        let now = now_secs();
311        sqlx::query(
312            "UPDATE admin_users SET totp_secret = totp_pending_secret, \
313             totp_pending_secret = NULL, totp_last_step = NULL, updated_at = ? \
314             WHERE id = ? AND totp_pending_secret IS NOT NULL;",
315        )
316        .bind(now)
317        .bind(self.id)
318        .execute(&database.pool)
319        .await?;
320
321        self.totp_secret = Some(pending);
322        self.totp_pending_secret = None;
323        self.totp_last_step = None;
324        self.updated_at = now;
325        info!(event = "db_admin_totp_enabled", outcome = "success", user_id = %self.id, username = %self.username);
326        Ok(())
327    }
328
329    /// Removes the factor, any half-finished enrolment and the replay guard
330    /// together. Callers drop the recovery codes too -- a code that recovers
331    /// access to a factor that no longer exists is a second password.
332    pub async fn clear_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
333        let now = now_secs();
334        sqlx::query(
335            "UPDATE admin_users SET totp_secret = NULL, totp_pending_secret = NULL, \
336             totp_last_step = NULL, updated_at = ? WHERE id = ?;",
337        )
338        .bind(now)
339        .bind(self.id)
340        .execute(&database.pool)
341        .await?;
342
343        self.totp_secret = None;
344        self.totp_pending_secret = None;
345        self.totp_last_step = None;
346        self.updated_at = now;
347        info!(event = "db_admin_totp_disabled", outcome = "success", user_id = %self.id, username = %self.username);
348        Ok(())
349    }
350
351    /// Records `step` as accepted, refusing one that is not strictly newer than
352    /// the stored value -- RFC 6238 §5.2's replay guard.
353    ///
354    /// The comparison lives in the `WHERE` clause rather than in Rust: a code
355    /// observed in flight and resubmitted inside its own 30-second window must
356    /// not be accepted twice, and with two requests racing it is
357    /// `rows_affected` that decides which one was first. Same primitive as
358    /// `Nonce::verify`.
359    pub async fn claim_totp_step(
360        &mut self,
361        step: i64,
362        database: &Database,
363    ) -> Result<bool, sqlx::Error> {
364        let now = now_secs();
365        let result = sqlx::query(
366            "UPDATE admin_users SET totp_last_step = ?, updated_at = ? \
367             WHERE id = ? AND (totp_last_step IS NULL OR totp_last_step < ?);",
368        )
369        .bind(step)
370        .bind(now)
371        .bind(self.id)
372        .bind(step)
373        .execute(&database.pool)
374        .await?;
375
376        let claimed = result.rows_affected() == 1;
377        if claimed {
378            self.totp_last_step = Some(step);
379            self.updated_at = now;
380        }
381        Ok(claimed)
382    }
383
384    /// Stamps `last_login_at`. Advisory only -- nothing authorises on it.
385    ///
386    /// Called when a login *completes*, which for an operator with a second
387    /// factor is one request later than the password being accepted.
388    pub async fn mark_logged_in(&mut self, database: &Database) -> Result<(), sqlx::Error> {
389        let now = now_secs();
390        sqlx::query("UPDATE admin_users SET last_login_at = ? WHERE id = ?;")
391            .bind(now)
392            .bind(self.id)
393            .execute(&database.pool)
394            .await?;
395
396        self.last_login_at = Some(now);
397        Ok(())
398    }
399
400    /// Removes the operator. Their sessions go with them via the schema's
401    /// `ON DELETE CASCADE`, which needs `foreign_keys` on -- `Database::connect`
402    /// and `connect_in_memory` both pin it. Returns whether a row existed.
403    pub async fn delete(id: Uuid, database: &Database) -> Result<bool, sqlx::Error> {
404        debug!(event = "db_admin_user_delete_started", outcome = "progress", id = ?id);
405        let result = sqlx::query("DELETE FROM admin_users WHERE id = ?;")
406            .bind(id)
407            .execute(&database.pool)
408            .await?;
409
410        let deleted = result.rows_affected() > 0;
411        if deleted {
412            info!(event = "db_admin_user_deleted", outcome = "success", user_id = %id);
413        }
414        Ok(deleted)
415    }
416
417    /// Whether this operator may log in and hold a session.
418    #[must_use]
419    pub fn is_active(&self) -> bool {
420        self.status == "active"
421    }
422
423    /// Whether a confirmed second factor is configured. A pending enrolment
424    /// does not count -- it has never been proven against a code.
425    #[must_use]
426    pub fn has_totp(&self) -> bool {
427        self.totp_secret.is_some()
428    }
429
430    /// Whether an enrolment is half-finished: a secret was generated and shown,
431    /// and no code has proven it yet.
432    ///
433    /// Deliberately not folded into [`AdminUser::has_totp`] and deliberately
434    /// not in [`AdminUser::to_json`]: the login path must treat this operator as
435    /// having *no* factor, and the only surface that cares is the enrolment
436    /// page deciding whether to offer "start over".
437    #[must_use]
438    pub fn has_pending_totp(&self) -> bool {
439        self.totp_pending_secret.is_some()
440    }
441
442    /// The admin-facing rendering. **Never** includes `password_hash`, nor
443    /// either TOTP secret -- only whether one is configured.
444    #[must_use]
445    pub fn to_json(&self) -> Value {
446        serde_json::json!({
447            "id": self.id,
448            "username": self.username,
449            "status": self.status,
450            "totpEnabled": self.has_totp(),
451            "createdAt": rfc3339(self.created_at),
452            "updatedAt": rfc3339(self.updated_at),
453            "lastLoginAt": self.last_login_at.map(rfc3339),
454        })
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::sync::Arc;
462
463    async fn db() -> Arc<Database> {
464        Arc::new(Database::connect_in_memory().await.unwrap())
465    }
466
467    #[tokio::test]
468    async fn create_persists_an_active_user_with_a_lowercased_username() {
469        let db = db().await;
470        let user = AdminUser::create("  Alice  ", "hash", &db).await.unwrap();
471        assert_eq!(user.username, "alice");
472        assert_eq!(user.status, "active");
473        assert!(user.is_active());
474        assert!(user.last_login_at.is_none());
475        assert!(!user.has_totp());
476    }
477
478    #[tokio::test]
479    async fn find_by_username_is_case_insensitive_and_round_trips() {
480        let db = db().await;
481        let created = AdminUser::create("alice", "hash", &db).await.unwrap();
482        let found = AdminUser::find_by_username("ALICE", &db)
483            .await
484            .unwrap()
485            .unwrap();
486        assert_eq!(found.id, created.id);
487        assert_eq!(found.password_hash, "hash");
488
489        let by_id = AdminUser::find_by_id(created.id, &db)
490            .await
491            .unwrap()
492            .unwrap();
493        assert_eq!(by_id.username, "alice");
494    }
495
496    #[tokio::test]
497    async fn lookups_of_unknown_users_return_none() {
498        let db = db().await;
499        assert!(
500            AdminUser::find_by_username("nobody", &db)
501                .await
502                .unwrap()
503                .is_none()
504        );
505        assert!(
506            AdminUser::find_by_id(crate::sqlite::id::mint(), &db)
507                .await
508                .unwrap()
509                .is_none()
510        );
511    }
512
513    #[tokio::test]
514    async fn a_duplicate_username_is_refused_by_the_unique_constraint() {
515        let db = db().await;
516        AdminUser::create("alice", "hash", &db).await.unwrap();
517        // Also proves the normalization above is a real constraint, not a
518        // near-miss: `Alice` collides with the stored `alice`.
519        let error = AdminUser::create("Alice", "other", &db).await.unwrap_err();
520        assert!(
521            error.to_string().to_lowercase().contains("unique"),
522            "expected a UNIQUE violation, got: {error}"
523        );
524    }
525
526    #[tokio::test]
527    async fn list_all_returns_every_user_and_empty_is_empty() {
528        let db = db().await;
529        assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
530
531        AdminUser::create("a", "h", &db).await.unwrap();
532        AdminUser::create("b", "h", &db).await.unwrap();
533        let all = AdminUser::list_all(&db).await.unwrap();
534        assert_eq!(all.len(), 2);
535        // Two users created in the same second tie on `created_at`, so the
536        // `id ASC` tiebreak decides -- and since `sqlite::id::mint` is a UUID
537        // v7, whose leading 48 bits are a millisecond timestamp, that tiebreak
538        // is insertion order. It used to be a random UUID, so this assertion
539        // failed one run in two and the test sorted the names before making it.
540        //
541        // Only for rows minted since that change: nothing was backfilled, so a
542        // v4 still ties arbitrarily against a v7 in the same second, for ever.
543        let names: Vec<&str> = all.iter().map(|u| u.username.as_str()).collect();
544        assert_eq!(names, ["a", "b"], "the v7 tiebreak is insertion order");
545    }
546
547    /// The window `admin user list` hands down. Both facts a page needs: the
548    /// rows it holds, and the total it is a page *of*.
549    #[tokio::test]
550    async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
551        let db = db().await;
552        assert_eq!(AdminUser::search(50, 0, &db).await.unwrap().1, 0);
553
554        for name in ["a", "b", "c", "d", "e"] {
555            AdminUser::create(name, "h", &db).await.unwrap();
556        }
557
558        let (first, total) = AdminUser::search(2, 0, &db).await.unwrap();
559        let (second, also_total) = AdminUser::search(2, 2, &db).await.unwrap();
560        let (third, _) = AdminUser::search(2, 4, &db).await.unwrap();
561
562        assert_eq!((total, also_total), (5, 5), "the total is the table");
563        assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
564
565        // Walked end to end the pages are the table exactly once, which is both
566        // "no overlap" and "nothing skipped" in one assertion -- and in the
567        // creation order the `id` tiebreak preserves, since all five tie on
568        // `created_at`.
569        let walked: Vec<&str> = first
570            .iter()
571            .chain(second.iter())
572            .chain(third.iter())
573            .map(|user| user.username.as_str())
574            .collect();
575        assert_eq!(walked, ["a", "b", "c", "d", "e"]);
576    }
577
578    /// Oldest first, and the only listing in the binary that is: the bootstrap
579    /// operator is the row whose position should not move as colleagues are
580    /// added.
581    #[tokio::test]
582    async fn search_reads_the_table_in_the_same_order_as_the_scan() {
583        let db = db().await;
584        for name in ["a", "b", "c"] {
585            AdminUser::create(name, "h", &db).await.unwrap();
586        }
587
588        let scanned: Vec<String> = AdminUser::list_all(&db)
589            .await
590            .unwrap()
591            .into_iter()
592            .map(|user| user.username)
593            .collect();
594        let paged: Vec<String> = AdminUser::search(50, 0, &db)
595            .await
596            .unwrap()
597            .0
598            .into_iter()
599            .map(|user| user.username)
600            .collect();
601        assert_eq!(paged, scanned);
602    }
603
604    #[tokio::test]
605    async fn list_all_orders_oldest_first() {
606        let db = db().await;
607        let older = AdminUser::create("older", "h", &db).await.unwrap();
608        let newer = AdminUser::create("newer", "h", &db).await.unwrap();
609        // Backdate one so the two no longer tie and `created_at ASC` is what
610        // decides, rather than the UUID tiebreak.
611        sqlx::query("UPDATE admin_users SET created_at = ? WHERE id = ?;")
612            .bind(older.created_at - 60)
613            .bind(older.id)
614            .execute(&db.pool)
615            .await
616            .unwrap();
617
618        let all = AdminUser::list_all(&db).await.unwrap();
619        assert_eq!(all[0].id, older.id);
620        assert_eq!(all[1].id, newer.id);
621    }
622
623    #[tokio::test]
624    async fn set_password_hash_persists_and_syncs_in_memory() {
625        let db = db().await;
626        let mut user = AdminUser::create("alice", "old", &db).await.unwrap();
627        user.set_password_hash("new", &db).await.unwrap();
628        assert_eq!(user.password_hash, "new");
629
630        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
631        assert_eq!(reloaded.password_hash, "new");
632    }
633
634    #[tokio::test]
635    async fn set_status_persists_and_disables() {
636        let db = db().await;
637        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
638        user.set_status("disabled", &db).await.unwrap();
639        assert!(!user.is_active());
640
641        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
642        assert!(!reloaded.is_active());
643    }
644
645    #[tokio::test]
646    async fn the_totp_setters_persist_and_sync_in_memory() {
647        let db = db().await;
648        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
649
650        user.set_totp_pending(b"secret-bytes", &db).await.unwrap();
651        assert!(user.has_pending_totp());
652        assert!(
653            !user.has_totp(),
654            "a pending enrolment must not read as a second factor"
655        );
656        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
657        assert_eq!(
658            reloaded.totp_pending_secret.as_deref(),
659            Some(&b"secret-bytes"[..])
660        );
661        assert!(!reloaded.has_totp());
662
663        user.confirm_totp(&db).await.unwrap();
664        assert!(user.has_totp());
665        assert!(!user.has_pending_totp());
666        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
667        assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"secret-bytes"[..]));
668        assert_eq!(reloaded.totp_pending_secret, None);
669
670        user.clear_totp(&db).await.unwrap();
671        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
672        assert_eq!(reloaded.totp_secret, None);
673        assert_eq!(reloaded.totp_pending_secret, None);
674        assert_eq!(reloaded.totp_last_step, None);
675    }
676
677    /// A double-submit of the confirm form must not clear a live factor by
678    /// promoting a pending column that is already empty.
679    #[tokio::test]
680    async fn confirming_with_nothing_pending_leaves_a_live_factor_alone() {
681        let db = db().await;
682        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
683        user.set_totp_pending(b"live", &db).await.unwrap();
684        user.confirm_totp(&db).await.unwrap();
685
686        user.confirm_totp(&db).await.unwrap();
687
688        assert!(user.has_totp());
689        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
690        assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"live"[..]));
691    }
692
693    /// RFC 6238 §5.2's replay guard, and the reason the comparison is in SQL:
694    /// two requests carrying one code must not both be accepted.
695    #[tokio::test]
696    async fn claim_totp_step_refuses_a_step_it_has_already_seen() {
697        let db = db().await;
698        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
699
700        assert!(user.claim_totp_step(100, &db).await.unwrap());
701        assert_eq!(user.totp_last_step, Some(100));
702
703        // The same step, and any earlier one, are spent.
704        assert!(!user.claim_totp_step(100, &db).await.unwrap());
705        assert!(!user.claim_totp_step(99, &db).await.unwrap());
706        assert_eq!(
707            user.totp_last_step,
708            Some(100),
709            "a refused claim must not move the guard"
710        );
711
712        // Strictly newer advances it, and persists.
713        assert!(user.claim_totp_step(101, &db).await.unwrap());
714        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
715        assert_eq!(reloaded.totp_last_step, Some(101));
716    }
717
718    #[tokio::test]
719    async fn the_status_check_refuses_a_value_outside_the_schema() {
720        let db = db().await;
721        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
722        assert!(user.set_status("banished", &db).await.is_err());
723    }
724
725    #[tokio::test]
726    async fn mark_logged_in_stamps_last_login_at() {
727        let db = db().await;
728        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
729        user.mark_logged_in(&db).await.unwrap();
730        assert!(user.last_login_at.is_some());
731
732        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
733        assert_eq!(reloaded.last_login_at, user.last_login_at);
734    }
735
736    #[tokio::test]
737    async fn delete_reports_whether_a_row_existed() {
738        let db = db().await;
739        let user = AdminUser::create("alice", "h", &db).await.unwrap();
740        assert!(AdminUser::delete(user.id, &db).await.unwrap());
741        assert!(!AdminUser::delete(user.id, &db).await.unwrap());
742    }
743
744    #[tokio::test]
745    async fn to_json_never_leaks_the_hash_or_the_totp_secret() {
746        let db = db().await;
747        let user = AdminUser::create("alice", "super-secret-hash", &db)
748            .await
749            .unwrap();
750        let json = user.to_json();
751        assert!(json.get("password_hash").is_none());
752        assert!(json.get("passwordHash").is_none());
753        assert!(json.get("totpSecret").is_none());
754        assert!(!json.to_string().contains("super-secret-hash"));
755        assert_eq!(json["username"], "alice");
756        assert_eq!(json["status"], "active");
757        assert_eq!(json["totpEnabled"], false);
758        assert_eq!(json["lastLoginAt"], Value::Null);
759    }
760}