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: String,
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: Uuid::new_v4().to_string(),
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: &str,
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 -- `admin user list`.
163    pub async fn list_all(database: &Database) -> Result<Vec<AdminUser>, sqlx::Error> {
164        debug!(
165            event = "db_admin_user_list_all_started",
166            outcome = "progress"
167        );
168        let rows = sqlx::query(concat!(
169            "SELECT ",
170            columns!(),
171            " FROM admin_users ORDER BY created_at ASC, id ASC;"
172        ))
173        .fetch_all(&database.pool)
174        .await?;
175
176        rows.into_iter().map(AdminUser::from_row).collect()
177    }
178
179    /// Replaces the stored hash. Callers are responsible for invalidating the
180    /// owner's sessions -- `admin::users::set_password` does, and a password
181    /// change that left them alive would be a change in name only.
182    pub async fn set_password_hash(
183        &mut self,
184        password_hash: &str,
185        database: &Database,
186    ) -> Result<(), sqlx::Error> {
187        let now = now_secs();
188        sqlx::query("UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?;")
189            .bind(password_hash)
190            .bind(now)
191            .bind(&self.id)
192            .execute(&database.pool)
193            .await?;
194
195        self.password_hash = password_hash.to_string();
196        self.updated_at = now;
197        info!(event = "db_admin_user_password_changed", outcome = "success", user_id = %self.id, username = %self.username);
198        Ok(())
199    }
200
201    /// Moves between `active` and `disabled`. A disabled operator cannot log
202    /// in, and an existing session of theirs is refused on its next use --
203    /// the session rows are left for the reaper rather than deleted here, so
204    /// re-enabling is a single UPDATE either way.
205    pub async fn set_status(
206        &mut self,
207        status: &str,
208        database: &Database,
209    ) -> Result<(), sqlx::Error> {
210        let now = now_secs();
211        sqlx::query("UPDATE admin_users SET status = ?, updated_at = ? WHERE id = ?;")
212            .bind(status)
213            .bind(now)
214            .bind(&self.id)
215            .execute(&database.pool)
216            .await?;
217
218        self.status = status.to_string();
219        self.updated_at = now;
220        info!(event = "db_admin_user_status_changed", outcome = "success", user_id = %self.id, username = %self.username, status = %status);
221        Ok(())
222    }
223
224    /// Stores an enrolment the owner has not yet proven a code against.
225    ///
226    /// Not a usable second factor: [`AdminUser::has_totp`] stays `false` until
227    /// [`AdminUser::confirm_totp`] moves it across, which is what stops an
228    /// abandoned enrolment from locking its own owner out.
229    pub async fn set_totp_pending(
230        &mut self,
231        secret: &[u8],
232        database: &Database,
233    ) -> Result<(), sqlx::Error> {
234        let now = now_secs();
235        sqlx::query("UPDATE admin_users SET totp_pending_secret = ?, updated_at = ? WHERE id = ?;")
236            .bind(secret)
237            .bind(now)
238            .bind(&self.id)
239            .execute(&database.pool)
240            .await?;
241
242        self.totp_pending_secret = Some(secret.to_vec());
243        self.updated_at = now;
244        info!(event = "db_admin_totp_enrolment_started", outcome = "progress", user_id = %self.id, username = %self.username);
245        Ok(())
246    }
247
248    /// Promotes the pending secret to the real one.
249    ///
250    /// One statement, deliberately: a half-applied enrolment would leave the
251    /// operator believing they have a factor that nothing checks, or holding a
252    /// pending secret alongside a live one. `totp_last_step` is cleared with
253    /// them -- the replay guard belongs to the secret it was recorded against.
254    ///
255    /// A no-op when nothing is pending, so a double-submit cannot clear a live
256    /// factor.
257    pub async fn confirm_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
258        let Some(pending) = self.totp_pending_secret.clone() else {
259            return Ok(());
260        };
261
262        let now = now_secs();
263        sqlx::query(
264            "UPDATE admin_users SET totp_secret = totp_pending_secret, \
265             totp_pending_secret = NULL, totp_last_step = NULL, updated_at = ? \
266             WHERE id = ? AND totp_pending_secret IS NOT NULL;",
267        )
268        .bind(now)
269        .bind(&self.id)
270        .execute(&database.pool)
271        .await?;
272
273        self.totp_secret = Some(pending);
274        self.totp_pending_secret = None;
275        self.totp_last_step = None;
276        self.updated_at = now;
277        info!(event = "db_admin_totp_enabled", outcome = "success", user_id = %self.id, username = %self.username);
278        Ok(())
279    }
280
281    /// Removes the factor, any half-finished enrolment and the replay guard
282    /// together. Callers drop the recovery codes too -- a code that recovers
283    /// access to a factor that no longer exists is a second password.
284    pub async fn clear_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
285        let now = now_secs();
286        sqlx::query(
287            "UPDATE admin_users SET totp_secret = NULL, totp_pending_secret = NULL, \
288             totp_last_step = NULL, updated_at = ? WHERE id = ?;",
289        )
290        .bind(now)
291        .bind(&self.id)
292        .execute(&database.pool)
293        .await?;
294
295        self.totp_secret = None;
296        self.totp_pending_secret = None;
297        self.totp_last_step = None;
298        self.updated_at = now;
299        info!(event = "db_admin_totp_disabled", outcome = "success", user_id = %self.id, username = %self.username);
300        Ok(())
301    }
302
303    /// Records `step` as accepted, refusing one that is not strictly newer than
304    /// the stored value -- RFC 6238 §5.2's replay guard.
305    ///
306    /// The comparison lives in the `WHERE` clause rather than in Rust: a code
307    /// observed in flight and resubmitted inside its own 30-second window must
308    /// not be accepted twice, and with two requests racing it is
309    /// `rows_affected` that decides which one was first. Same primitive as
310    /// `Nonce::verify`.
311    pub async fn claim_totp_step(
312        &mut self,
313        step: i64,
314        database: &Database,
315    ) -> Result<bool, sqlx::Error> {
316        let now = now_secs();
317        let result = sqlx::query(
318            "UPDATE admin_users SET totp_last_step = ?, updated_at = ? \
319             WHERE id = ? AND (totp_last_step IS NULL OR totp_last_step < ?);",
320        )
321        .bind(step)
322        .bind(now)
323        .bind(&self.id)
324        .bind(step)
325        .execute(&database.pool)
326        .await?;
327
328        let claimed = result.rows_affected() == 1;
329        if claimed {
330            self.totp_last_step = Some(step);
331            self.updated_at = now;
332        }
333        Ok(claimed)
334    }
335
336    /// Stamps `last_login_at`. Advisory only -- nothing authorises on it.
337    ///
338    /// Called when a login *completes*, which for an operator with a second
339    /// factor is one request later than the password being accepted.
340    pub async fn mark_logged_in(&mut self, database: &Database) -> Result<(), sqlx::Error> {
341        let now = now_secs();
342        sqlx::query("UPDATE admin_users SET last_login_at = ? WHERE id = ?;")
343            .bind(now)
344            .bind(&self.id)
345            .execute(&database.pool)
346            .await?;
347
348        self.last_login_at = Some(now);
349        Ok(())
350    }
351
352    /// Removes the operator. Their sessions go with them via the schema's
353    /// `ON DELETE CASCADE`, which needs `foreign_keys` on -- `Database::connect`
354    /// and `connect_in_memory` both pin it. Returns whether a row existed.
355    pub async fn delete(id: &str, database: &Database) -> Result<bool, sqlx::Error> {
356        debug!(event = "db_admin_user_delete_started", outcome = "progress", id = ?id);
357        let result = sqlx::query("DELETE FROM admin_users WHERE id = ?;")
358            .bind(id)
359            .execute(&database.pool)
360            .await?;
361
362        let deleted = result.rows_affected() > 0;
363        if deleted {
364            info!(event = "db_admin_user_deleted", outcome = "success", user_id = %id);
365        }
366        Ok(deleted)
367    }
368
369    /// Whether this operator may log in and hold a session.
370    #[must_use]
371    pub fn is_active(&self) -> bool {
372        self.status == "active"
373    }
374
375    /// Whether a confirmed second factor is configured. A pending enrolment
376    /// does not count -- it has never been proven against a code.
377    #[must_use]
378    pub fn has_totp(&self) -> bool {
379        self.totp_secret.is_some()
380    }
381
382    /// Whether an enrolment is half-finished: a secret was generated and shown,
383    /// and no code has proven it yet.
384    ///
385    /// Deliberately not folded into [`AdminUser::has_totp`] and deliberately
386    /// not in [`AdminUser::to_json`]: the login path must treat this operator as
387    /// having *no* factor, and the only surface that cares is the enrolment
388    /// page deciding whether to offer "start over".
389    #[must_use]
390    pub fn has_pending_totp(&self) -> bool {
391        self.totp_pending_secret.is_some()
392    }
393
394    /// The admin-facing rendering. **Never** includes `password_hash`, nor
395    /// either TOTP secret -- only whether one is configured.
396    #[must_use]
397    pub fn to_json(&self) -> Value {
398        serde_json::json!({
399            "id": self.id,
400            "username": self.username,
401            "status": self.status,
402            "totpEnabled": self.has_totp(),
403            "createdAt": rfc3339(self.created_at),
404            "updatedAt": rfc3339(self.updated_at),
405            "lastLoginAt": self.last_login_at.map(rfc3339),
406        })
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::sync::Arc;
414
415    async fn db() -> Arc<Database> {
416        Arc::new(Database::connect_in_memory().await.unwrap())
417    }
418
419    #[tokio::test]
420    async fn create_persists_an_active_user_with_a_lowercased_username() {
421        let db = db().await;
422        let user = AdminUser::create("  Alice  ", "hash", &db).await.unwrap();
423        assert_eq!(user.username, "alice");
424        assert_eq!(user.status, "active");
425        assert!(user.is_active());
426        assert!(user.last_login_at.is_none());
427        assert!(!user.has_totp());
428    }
429
430    #[tokio::test]
431    async fn find_by_username_is_case_insensitive_and_round_trips() {
432        let db = db().await;
433        let created = AdminUser::create("alice", "hash", &db).await.unwrap();
434        let found = AdminUser::find_by_username("ALICE", &db)
435            .await
436            .unwrap()
437            .unwrap();
438        assert_eq!(found.id, created.id);
439        assert_eq!(found.password_hash, "hash");
440
441        let by_id = AdminUser::find_by_id(&created.id, &db)
442            .await
443            .unwrap()
444            .unwrap();
445        assert_eq!(by_id.username, "alice");
446    }
447
448    #[tokio::test]
449    async fn lookups_of_unknown_users_return_none() {
450        let db = db().await;
451        assert!(
452            AdminUser::find_by_username("nobody", &db)
453                .await
454                .unwrap()
455                .is_none()
456        );
457        assert!(AdminUser::find_by_id("nope", &db).await.unwrap().is_none());
458    }
459
460    #[tokio::test]
461    async fn a_duplicate_username_is_refused_by_the_unique_constraint() {
462        let db = db().await;
463        AdminUser::create("alice", "hash", &db).await.unwrap();
464        // Also proves the normalization above is a real constraint, not a
465        // near-miss: `Alice` collides with the stored `alice`.
466        let error = AdminUser::create("Alice", "other", &db).await.unwrap_err();
467        assert!(
468            error.to_string().to_lowercase().contains("unique"),
469            "expected a UNIQUE violation, got: {error}"
470        );
471    }
472
473    #[tokio::test]
474    async fn list_all_returns_every_user_and_empty_is_empty() {
475        let db = db().await;
476        assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
477
478        AdminUser::create("a", "h", &db).await.unwrap();
479        AdminUser::create("b", "h", &db).await.unwrap();
480        let all = AdminUser::list_all(&db).await.unwrap();
481        assert_eq!(all.len(), 2);
482        // Deliberately not asserting *which* comes first: two users created in
483        // the same second tie on `created_at`, and the `id ASC` tiebreak is a
484        // random UUID. The order is stable across reads, which is all a list
485        // owes its caller -- it is not insertion order, and asserting it was
486        // is how this test failed one run in two.
487        let mut names: Vec<&str> = all.iter().map(|u| u.username.as_str()).collect();
488        names.sort_unstable();
489        assert_eq!(names, ["a", "b"]);
490    }
491
492    #[tokio::test]
493    async fn list_all_orders_oldest_first() {
494        let db = db().await;
495        let older = AdminUser::create("older", "h", &db).await.unwrap();
496        let newer = AdminUser::create("newer", "h", &db).await.unwrap();
497        // Backdate one so the two no longer tie and `created_at ASC` is what
498        // decides, rather than the UUID tiebreak.
499        sqlx::query("UPDATE admin_users SET created_at = ? WHERE id = ?;")
500            .bind(older.created_at - 60)
501            .bind(&older.id)
502            .execute(&db.pool)
503            .await
504            .unwrap();
505
506        let all = AdminUser::list_all(&db).await.unwrap();
507        assert_eq!(all[0].id, older.id);
508        assert_eq!(all[1].id, newer.id);
509    }
510
511    #[tokio::test]
512    async fn set_password_hash_persists_and_syncs_in_memory() {
513        let db = db().await;
514        let mut user = AdminUser::create("alice", "old", &db).await.unwrap();
515        user.set_password_hash("new", &db).await.unwrap();
516        assert_eq!(user.password_hash, "new");
517
518        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
519        assert_eq!(reloaded.password_hash, "new");
520    }
521
522    #[tokio::test]
523    async fn set_status_persists_and_disables() {
524        let db = db().await;
525        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
526        user.set_status("disabled", &db).await.unwrap();
527        assert!(!user.is_active());
528
529        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
530        assert!(!reloaded.is_active());
531    }
532
533    #[tokio::test]
534    async fn the_totp_setters_persist_and_sync_in_memory() {
535        let db = db().await;
536        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
537
538        user.set_totp_pending(b"secret-bytes", &db).await.unwrap();
539        assert!(user.has_pending_totp());
540        assert!(
541            !user.has_totp(),
542            "a pending enrolment must not read as a second factor"
543        );
544        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
545        assert_eq!(
546            reloaded.totp_pending_secret.as_deref(),
547            Some(&b"secret-bytes"[..])
548        );
549        assert!(!reloaded.has_totp());
550
551        user.confirm_totp(&db).await.unwrap();
552        assert!(user.has_totp());
553        assert!(!user.has_pending_totp());
554        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
555        assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"secret-bytes"[..]));
556        assert_eq!(reloaded.totp_pending_secret, None);
557
558        user.clear_totp(&db).await.unwrap();
559        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
560        assert_eq!(reloaded.totp_secret, None);
561        assert_eq!(reloaded.totp_pending_secret, None);
562        assert_eq!(reloaded.totp_last_step, None);
563    }
564
565    /// A double-submit of the confirm form must not clear a live factor by
566    /// promoting a pending column that is already empty.
567    #[tokio::test]
568    async fn confirming_with_nothing_pending_leaves_a_live_factor_alone() {
569        let db = db().await;
570        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
571        user.set_totp_pending(b"live", &db).await.unwrap();
572        user.confirm_totp(&db).await.unwrap();
573
574        user.confirm_totp(&db).await.unwrap();
575
576        assert!(user.has_totp());
577        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
578        assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"live"[..]));
579    }
580
581    /// RFC 6238 §5.2's replay guard, and the reason the comparison is in SQL:
582    /// two requests carrying one code must not both be accepted.
583    #[tokio::test]
584    async fn claim_totp_step_refuses_a_step_it_has_already_seen() {
585        let db = db().await;
586        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
587
588        assert!(user.claim_totp_step(100, &db).await.unwrap());
589        assert_eq!(user.totp_last_step, Some(100));
590
591        // The same step, and any earlier one, are spent.
592        assert!(!user.claim_totp_step(100, &db).await.unwrap());
593        assert!(!user.claim_totp_step(99, &db).await.unwrap());
594        assert_eq!(
595            user.totp_last_step,
596            Some(100),
597            "a refused claim must not move the guard"
598        );
599
600        // Strictly newer advances it, and persists.
601        assert!(user.claim_totp_step(101, &db).await.unwrap());
602        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
603        assert_eq!(reloaded.totp_last_step, Some(101));
604    }
605
606    #[tokio::test]
607    async fn the_status_check_refuses_a_value_outside_the_schema() {
608        let db = db().await;
609        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
610        assert!(user.set_status("banished", &db).await.is_err());
611    }
612
613    #[tokio::test]
614    async fn mark_logged_in_stamps_last_login_at() {
615        let db = db().await;
616        let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
617        user.mark_logged_in(&db).await.unwrap();
618        assert!(user.last_login_at.is_some());
619
620        let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
621        assert_eq!(reloaded.last_login_at, user.last_login_at);
622    }
623
624    #[tokio::test]
625    async fn delete_reports_whether_a_row_existed() {
626        let db = db().await;
627        let user = AdminUser::create("alice", "h", &db).await.unwrap();
628        assert!(AdminUser::delete(&user.id, &db).await.unwrap());
629        assert!(!AdminUser::delete(&user.id, &db).await.unwrap());
630    }
631
632    #[tokio::test]
633    async fn to_json_never_leaks_the_hash_or_the_totp_secret() {
634        let db = db().await;
635        let user = AdminUser::create("alice", "super-secret-hash", &db)
636            .await
637            .unwrap();
638        let json = user.to_json();
639        assert!(json.get("password_hash").is_none());
640        assert!(json.get("passwordHash").is_none());
641        assert!(json.get("totpSecret").is_none());
642        assert!(!json.to_string().contains("super-secret-hash"));
643        assert_eq!(json["username"], "alice");
644        assert_eq!(json["status"], "active");
645        assert_eq!(json["totpEnabled"], false);
646        assert_eq!(json["lastLoginAt"], Value::Null);
647    }
648}