Skip to main content

allowthem_core/
users.rs

1use base64ct::{Base64UrlUnpadded, Encoding};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::db::Db;
7use crate::error::AuthError;
8use crate::event_sink::AuthEvent;
9use crate::handle::AllowThem;
10use crate::password::hash_password;
11use crate::types::{Email, User, UserId, Username};
12
13/// Map a SQLite UNIQUE constraint violation to `AuthError::Conflict`.
14///
15/// SQLite UNIQUE violations include the constraint name in the message,
16/// e.g. "UNIQUE constraint failed: allowthem_users.email".
17pub(crate) fn map_unique_violation(err: sqlx::Error) -> AuthError {
18    if let sqlx::Error::Database(ref db_err) = err {
19        let msg = db_err.message();
20        if msg.contains("UNIQUE constraint failed") {
21            if msg.contains("email") {
22                return AuthError::Conflict("email already exists".into());
23            }
24            if msg.contains("username") {
25                return AuthError::Conflict("username already exists".into());
26            }
27            return AuthError::Conflict(msg.to_string());
28        }
29    }
30    AuthError::Database(err)
31}
32
33/// Parameters for searching/filtering users in the admin directory.
34pub struct SearchUsersParams<'a> {
35    pub query: Option<&'a str>,
36    pub is_active: Option<bool>,
37    pub has_mfa: Option<bool>,
38    /// Optional filter on `email_verified`. `Some(true)` returns only users
39    /// with verified emails; `Some(false)` only unverified; `None` includes
40    /// both. Surfaced for the dashboard user-management page (99c.4).
41    pub email_verified: Option<bool>,
42    pub limit: u32,
43    pub offset: u32,
44}
45
46/// User with MFA enrollment status, for list display.
47#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
48pub struct UserListEntry {
49    pub id: UserId,
50    pub email: Email,
51    pub username: Option<Username>,
52    pub is_active: bool,
53    pub has_mfa: bool,
54    pub created_at: DateTime<Utc>,
55}
56
57/// Result of a paginated user search.
58pub struct SearchUsersResult {
59    pub users: Vec<UserListEntry>,
60    pub total: u32,
61}
62
63/// Opaque keyset cursor for paginating `list_users_paginated`.
64///
65/// Encodes `(created_at, id)` as a base64url-encoded JSON blob.
66pub struct UserCursor {
67    pub created_at: DateTime<Utc>,
68    pub id: UserId,
69}
70
71#[derive(Serialize, Deserialize)]
72struct RawUserCursor {
73    ca: String,
74    id: String,
75}
76
77impl UserCursor {
78    pub fn from_entry(entry: &UserListEntry) -> Self {
79        Self {
80            created_at: entry.created_at,
81            id: entry.id,
82        }
83    }
84
85    pub fn encode(&self) -> String {
86        let raw = RawUserCursor {
87            ca: self.created_at.to_rfc3339(),
88            id: self.id.to_string(),
89        };
90        let json = serde_json::to_string(&raw).expect("RawUserCursor serializes");
91        Base64UrlUnpadded::encode_string(json.as_bytes())
92    }
93
94    pub fn decode(s: &str) -> Option<Self> {
95        let bytes = Base64UrlUnpadded::decode_vec(s).ok()?;
96        let raw: RawUserCursor = serde_json::from_slice(&bytes).ok()?;
97        let created_at = chrono::DateTime::parse_from_rfc3339(&raw.ca)
98            .ok()?
99            .with_timezone(&Utc);
100        let id = raw.id.parse::<uuid::Uuid>().ok().map(UserId::from_uuid)?;
101        Some(Self { created_at, id })
102    }
103}
104
105impl Db {
106    /// Count of all users in the tenant DB. Used by the SaaS super-admin
107    /// tenant detail panel (99c.6 §6.1).
108    pub async fn count_users(&self) -> Result<u64, AuthError> {
109        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_users")
110            .fetch_one(self.pool())
111            .await
112            .map_err(AuthError::Database)?;
113        Ok(n as u64)
114    }
115
116    /// Create a user with email, plaintext password, optional username, and optional custom data.
117    ///
118    /// Hashes the password with Argon2id (via `password::hash_password`).
119    /// Returns the created User (without password_hash in the returned struct).
120    pub async fn create_user(
121        &self,
122        email: Email,
123        password: &str,
124        username: Option<Username>,
125        custom_data: Option<&Value>,
126    ) -> Result<User, AuthError> {
127        let id = UserId::new();
128        let pw_hash = hash_password(password)?;
129        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
130
131        sqlx::query(
132            "INSERT INTO allowthem_users \
133             (id, email, username, password_hash, email_verified, is_active, created_at, updated_at, custom_data) \
134             VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5, ?6)",
135        )
136        .bind(id)
137        .bind(&email)
138        .bind(&username)
139        .bind(&pw_hash)
140        .bind(&now)
141        .bind(custom_data.map(sqlx::types::Json))
142        .execute(self.pool())
143        .await
144        .map_err(map_unique_violation)?;
145
146        self.get_user(id).await
147    }
148
149    /// Import a user with a pre-existing password hash (for migration from external systems).
150    /// The hash must be a valid Argon2 PHC string. No validation is performed on it.
151    pub async fn create_user_with_hash(
152        &self,
153        email: Email,
154        password_hash: &str,
155        username: Option<Username>,
156        custom_data: Option<&Value>,
157    ) -> Result<User, AuthError> {
158        let id = UserId::new();
159        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
160
161        sqlx::query(
162            "INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at, custom_data)
163             VALUES (?1, ?2, ?3, ?4, 0, 1, ?5, ?5, ?6)",
164        )
165        .bind(id)
166        .bind(&email)
167        .bind(&username)
168        .bind(password_hash)
169        .bind(&now)
170        .bind(custom_data.map(sqlx::types::Json))
171        .execute(self.pool())
172        .await
173        .map_err(map_unique_violation)?;
174
175        self.get_user(id).await
176    }
177
178    /// Look up a user by ID. Returns User with password_hash = None.
179    pub async fn get_user(&self, id: UserId) -> Result<User, AuthError> {
180        sqlx::query_as::<_, User>(
181            "SELECT id, email, username, NULL as password_hash, \
182             email_verified, is_active, created_at, updated_at, custom_data \
183             FROM allowthem_users WHERE id = ?",
184        )
185        .bind(id)
186        .fetch_optional(self.pool())
187        .await?
188        .ok_or(AuthError::NotFound)
189    }
190
191    /// Look up a user by email. Returns User with password_hash = None.
192    pub async fn get_user_by_email(&self, email: &Email) -> Result<User, AuthError> {
193        sqlx::query_as::<_, User>(
194            "SELECT id, email, username, NULL as password_hash, \
195             email_verified, is_active, created_at, updated_at, custom_data \
196             FROM allowthem_users WHERE email = ?",
197        )
198        .bind(email)
199        .fetch_optional(self.pool())
200        .await?
201        .ok_or(AuthError::NotFound)
202    }
203
204    /// Look up a user by username. Returns User with password_hash = None.
205    pub async fn get_user_by_username(&self, username: &Username) -> Result<User, AuthError> {
206        sqlx::query_as::<_, User>(
207            "SELECT id, email, username, NULL as password_hash, \
208             email_verified, is_active, created_at, updated_at, custom_data \
209             FROM allowthem_users WHERE username = ?",
210        )
211        .bind(username)
212        .fetch_optional(self.pool())
213        .await?
214        .ok_or(AuthError::NotFound)
215    }
216
217    /// Look up a user by email OR username for login.
218    ///
219    /// Returns User WITH password_hash populated. The caller is responsible
220    /// for calling `verify_password()` to check the password.
221    pub async fn find_for_login(&self, identifier: &str) -> Result<User, AuthError> {
222        sqlx::query_as::<_, User>(
223            "SELECT id, email, username, password_hash, \
224             email_verified, is_active, created_at, updated_at, custom_data \
225             FROM allowthem_users WHERE email = ?1 OR username = ?1",
226        )
227        .bind(identifier)
228        .fetch_optional(self.pool())
229        .await?
230        .ok_or(AuthError::NotFound)
231    }
232
233    /// Update a user's email. Also updates updated_at.
234    pub async fn update_user_email(&self, id: UserId, email: Email) -> Result<(), AuthError> {
235        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
236        let result =
237            sqlx::query("UPDATE allowthem_users SET email = ?1, updated_at = ?2 WHERE id = ?3")
238                .bind(&email)
239                .bind(&now)
240                .bind(id)
241                .execute(self.pool())
242                .await
243                .map_err(map_unique_violation)?;
244
245        if result.rows_affected() == 0 {
246            return Err(AuthError::NotFound);
247        }
248        Ok(())
249    }
250
251    /// Update a user's username (set or clear). Also updates updated_at.
252    pub async fn update_user_username(
253        &self,
254        id: UserId,
255        username: Option<Username>,
256    ) -> Result<(), AuthError> {
257        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
258        let result =
259            sqlx::query("UPDATE allowthem_users SET username = ?1, updated_at = ?2 WHERE id = ?3")
260                .bind(&username)
261                .bind(&now)
262                .bind(id)
263                .execute(self.pool())
264                .await
265                .map_err(map_unique_violation)?;
266
267        if result.rows_affected() == 0 {
268            return Err(AuthError::NotFound);
269        }
270        Ok(())
271    }
272
273    /// Update a user's is_active flag. Also updates updated_at.
274    pub async fn update_user_active(&self, id: UserId, is_active: bool) -> Result<(), AuthError> {
275        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
276        let result =
277            sqlx::query("UPDATE allowthem_users SET is_active = ?1, updated_at = ?2 WHERE id = ?3")
278                .bind(is_active)
279                .bind(&now)
280                .bind(id)
281                .execute(self.pool())
282                .await?;
283
284        if result.rows_affected() == 0 {
285            return Err(AuthError::NotFound);
286        }
287        Ok(())
288    }
289
290    /// Update a user's `email_verified` flag. Also updates `updated_at`.
291    ///
292    /// Pool-level helper for callers that update verification status outside
293    /// the token-redemption transaction (e.g. the seed-admin CLI marking a
294    /// freshly created super-admin verified). The transactional update inside
295    /// `verify_email` keeps its inline `UPDATE` to stay atomic with the
296    /// "mark token used" write.
297    pub async fn set_email_verified(&self, id: UserId, verified: bool) -> Result<(), AuthError> {
298        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
299        let result = sqlx::query(
300            "UPDATE allowthem_users SET email_verified = ?1, updated_at = ?2 WHERE id = ?3",
301        )
302        .bind(verified)
303        .bind(&now)
304        .bind(id)
305        .execute(self.pool())
306        .await?;
307
308        if result.rows_affected() == 0 {
309            return Err(AuthError::NotFound);
310        }
311        Ok(())
312    }
313
314    /// Delete a user by ID. Cascades to sessions, user_roles, user_permissions.
315    pub async fn delete_user(&self, id: UserId) -> Result<(), AuthError> {
316        let result = sqlx::query("DELETE FROM allowthem_users WHERE id = ?")
317            .bind(id)
318            .execute(self.pool())
319            .await?;
320
321        if result.rows_affected() == 0 {
322            return Err(AuthError::NotFound);
323        }
324        Ok(())
325    }
326
327    /// List all users ordered by `created_at ASC`. Returns User with `password_hash = None`.
328    pub async fn list_users(&self) -> Result<Vec<User>, AuthError> {
329        sqlx::query_as::<_, User>(
330            "SELECT id, email, username, NULL as password_hash, \
331             email_verified, is_active, created_at, updated_at, custom_data \
332             FROM allowthem_users ORDER BY created_at ASC",
333        )
334        .fetch_all(self.pool())
335        .await
336        .map_err(AuthError::Database)
337    }
338
339    /// Paginated list of users using a `(created_at, id)` keyset cursor.
340    ///
341    /// Limits are capped at 200. Pass `None` for cursor to start from the beginning.
342    /// Results are ordered oldest-first.
343    pub async fn list_users_paginated(
344        &self,
345        limit: u32,
346        cursor: Option<&UserCursor>,
347    ) -> Result<Vec<UserListEntry>, AuthError> {
348        let limit = (limit as i64).min(200);
349        match cursor {
350            None => sqlx::query_as::<_, UserListEntry>(
351                "SELECT u.id, u.email, u.username, u.is_active, \
352                 EXISTS (SELECT 1 FROM allowthem_mfa_secrets \
353                         WHERE user_id = u.id AND enabled = 1) AS has_mfa, \
354                 u.created_at \
355                 FROM allowthem_users u \
356                 ORDER BY u.created_at ASC, u.id ASC \
357                 LIMIT ?",
358            )
359            .bind(limit)
360            .fetch_all(self.pool())
361            .await
362            .map_err(AuthError::Database),
363            Some(c) => {
364                let ca = c.created_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
365                sqlx::query_as::<_, UserListEntry>(
366                    "SELECT u.id, u.email, u.username, u.is_active, \
367                     EXISTS (SELECT 1 FROM allowthem_mfa_secrets \
368                             WHERE user_id = u.id AND enabled = 1) AS has_mfa, \
369                     u.created_at \
370                     FROM allowthem_users u \
371                     WHERE (u.created_at > ?1 OR (u.created_at = ?1 AND u.id > ?2)) \
372                     ORDER BY u.created_at ASC, u.id ASC \
373                     LIMIT ?3",
374                )
375                .bind(&ca)
376                .bind(c.id)
377                .bind(limit)
378                .fetch_all(self.pool())
379                .await
380                .map_err(AuthError::Database)
381            }
382        }
383    }
384
385    /// Search and filter users with pagination.
386    ///
387    /// Builds a dynamic query with optional search term (matched against
388    /// email and username via LIKE), status filter, and MFA filter.
389    /// Returns matching users with their MFA enrollment status.
390    pub async fn search_users(
391        &self,
392        params: SearchUsersParams<'_>,
393    ) -> Result<SearchUsersResult, AuthError> {
394        let mut where_clauses: Vec<String> = Vec::new();
395        let mut bind_values: Vec<String> = Vec::new();
396
397        if let Some(q) = params.query {
398            let trimmed = q.trim();
399            if !trimmed.is_empty() {
400                let escaped = trimmed
401                    .replace('\\', "\\\\")
402                    .replace('%', "\\%")
403                    .replace('_', "\\_");
404                let pattern = format!("%{escaped}%");
405                where_clauses
406                    .push("(u.email LIKE ? ESCAPE '\\' OR u.username LIKE ? ESCAPE '\\')".into());
407                bind_values.push(pattern.clone());
408                bind_values.push(pattern);
409            }
410        }
411
412        if let Some(active) = params.is_active {
413            where_clauses.push("u.is_active = ?".into());
414            bind_values.push(if active { "1".into() } else { "0".into() });
415        }
416
417        if let Some(has_mfa) = params.has_mfa {
418            let exists = if has_mfa { "EXISTS" } else { "NOT EXISTS" };
419            where_clauses.push(format!(
420                "{exists} (SELECT 1 FROM allowthem_mfa_secrets WHERE user_id = u.id AND enabled = 1)"
421            ));
422        }
423
424        if let Some(verified) = params.email_verified {
425            where_clauses.push("u.email_verified = ?".into());
426            bind_values.push(if verified { "1".into() } else { "0".into() });
427        }
428
429        let where_sql = if where_clauses.is_empty() {
430            String::new()
431        } else {
432            format!("WHERE {}", where_clauses.join(" AND "))
433        };
434
435        let count_sql: &'static str = Box::leak(
436            format!("SELECT COUNT(*) FROM allowthem_users u {where_sql}").into_boxed_str(),
437        );
438        let mut count_query = sqlx::query_scalar::<_, i64>(count_sql);
439        for val in &bind_values {
440            count_query = count_query.bind(val);
441        }
442        let total = count_query
443            .fetch_one(self.pool())
444            .await
445            .map_err(AuthError::Database)? as u32;
446
447        let data_sql: &'static str = Box::leak(
448            format!(
449                "SELECT u.id, u.email, u.username, u.is_active, \
450                 EXISTS (SELECT 1 FROM allowthem_mfa_secrets \
451                         WHERE user_id = u.id AND enabled = 1) as has_mfa, \
452                 u.created_at \
453                 FROM allowthem_users u {where_sql} \
454                 ORDER BY u.created_at ASC \
455                 LIMIT ? OFFSET ?"
456            )
457            .into_boxed_str(),
458        );
459        let mut data_query = sqlx::query_as::<_, UserListEntry>(data_sql);
460        for val in &bind_values {
461            data_query = data_query.bind(val);
462        }
463        data_query = data_query.bind(params.limit).bind(params.offset);
464
465        let users = data_query
466            .fetch_all(self.pool())
467            .await
468            .map_err(AuthError::Database)?;
469
470        Ok(SearchUsersResult { users, total })
471    }
472
473    /// Update a user's password. Hashes `new_password` with Argon2id and stores it.
474    ///
475    /// Returns `AuthError::NotFound` if no user with `id` exists.
476    pub async fn update_user_password(
477        &self,
478        id: UserId,
479        new_password: &str,
480    ) -> Result<(), AuthError> {
481        let pw_hash = hash_password(new_password)?;
482        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
483        let result = sqlx::query(
484            "UPDATE allowthem_users SET password_hash = ?1, updated_at = ?2 WHERE id = ?3",
485        )
486        .bind(&pw_hash)
487        .bind(&now)
488        .bind(id)
489        .execute(self.pool())
490        .await?;
491
492        if result.rows_affected() == 0 {
493            return Err(AuthError::NotFound);
494        }
495        Ok(())
496    }
497
498    /// Set a user's password hash to NULL.
499    ///
500    /// Used by admin force-password-reset to invalidate the current password.
501    /// The login flow falls back to a dummy hash when `password_hash` is NULL,
502    /// so `verify_password` will always fail.
503    pub async fn clear_password_hash(&self, id: UserId) -> Result<(), AuthError> {
504        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
505        let result = sqlx::query(
506            "UPDATE allowthem_users SET password_hash = NULL, updated_at = ? WHERE id = ?",
507        )
508        .bind(&now)
509        .bind(id)
510        .execute(self.pool())
511        .await?;
512
513        if result.rows_affected() == 0 {
514            return Err(AuthError::NotFound);
515        }
516        Ok(())
517    }
518
519    /// Get a user's custom data.
520    ///
521    /// Returns `Err(NotFound)` if no user with `id` exists.
522    /// Returns `Ok(None)` if the user exists but has no custom data.
523    pub async fn get_custom_data(&self, id: &UserId) -> Result<Option<Value>, AuthError> {
524        let row: Option<(Option<Value>,)> =
525            sqlx::query_as("SELECT custom_data FROM allowthem_users WHERE id = ?")
526                .bind(id)
527                .fetch_optional(self.pool())
528                .await?;
529
530        match row {
531            None => Err(AuthError::NotFound),
532            Some((data,)) => Ok(data),
533        }
534    }
535
536    /// Set a user's custom data. Also updates `updated_at`.
537    ///
538    /// Returns `Err(NotFound)` if no user with `id` exists.
539    pub async fn set_custom_data(&self, id: &UserId, data: &Value) -> Result<(), AuthError> {
540        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
541        let result = sqlx::query(
542            "UPDATE allowthem_users SET custom_data = ?1, updated_at = ?2 WHERE id = ?3",
543        )
544        .bind(sqlx::types::Json(data))
545        .bind(&now)
546        .bind(id)
547        .execute(self.pool())
548        .await?;
549
550        if result.rows_affected() == 0 {
551            return Err(AuthError::NotFound);
552        }
553        Ok(())
554    }
555
556    /// Delete (clear) a user's custom data by setting it to NULL. Also updates `updated_at`.
557    ///
558    /// Idempotent -- succeeds even if custom data is already NULL.
559    pub async fn delete_custom_data(&self, id: &UserId) -> Result<(), AuthError> {
560        let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
561        sqlx::query("UPDATE allowthem_users SET custom_data = NULL, updated_at = ?1 WHERE id = ?2")
562            .bind(&now)
563            .bind(id)
564            .execute(self.pool())
565            .await?;
566
567        Ok(())
568    }
569}
570
571// ─── AllowThem wrappers ───────────────────────────────────────────────────────
572
573impl AllowThem {
574    /// Create a user and emit a `user.created` event on success.
575    pub async fn create_user(
576        &self,
577        email: Email,
578        password: &str,
579        username: Option<Username>,
580        custom_data: Option<&Value>,
581    ) -> Result<User, AuthError> {
582        let user = self
583            .db()
584            .create_user(email, password, username, custom_data)
585            .await?;
586        self.emit_event(AuthEvent::new(
587            "user.created",
588            Some(user.id),
589            serde_json::json!({
590                "user_id": user.id,
591                "email": user.email,
592                "username": user.username,
593            }),
594        ))
595        .await;
596        Ok(user)
597    }
598
599    /// Update a user's email address and emit a `user.updated` event on success.
600    pub async fn update_user_email(&self, id: UserId, email: Email) -> Result<(), AuthError> {
601        self.db().update_user_email(id, email).await?;
602        self.emit_event(AuthEvent::new(
603            "user.updated",
604            Some(id),
605            serde_json::json!({ "user_id": id, "field": "email" }),
606        ))
607        .await;
608        Ok(())
609    }
610
611    /// Update a user's username and emit a `user.updated` event on success.
612    pub async fn update_user_username(
613        &self,
614        id: UserId,
615        username: Option<Username>,
616    ) -> Result<(), AuthError> {
617        self.db().update_user_username(id, username).await?;
618        self.emit_event(AuthEvent::new(
619            "user.updated",
620            Some(id),
621            serde_json::json!({ "user_id": id, "field": "username" }),
622        ))
623        .await;
624        Ok(())
625    }
626
627    /// Update a user's password and emit a `password.changed` event on success.
628    pub async fn update_user_password(
629        &self,
630        id: UserId,
631        new_password: &str,
632    ) -> Result<(), AuthError> {
633        self.db().update_user_password(id, new_password).await?;
634        self.emit_event(AuthEvent::new(
635            "password.changed",
636            Some(id),
637            serde_json::json!({ "user_id": id }),
638        ))
639        .await;
640        Ok(())
641    }
642
643    /// Delete a user and emit a `user.deleted` event on success.
644    pub async fn delete_user(&self, id: UserId) -> Result<(), AuthError> {
645        self.db().delete_user(id).await?;
646        self.emit_event(AuthEvent::new(
647            "user.deleted",
648            Some(id),
649            serde_json::json!({ "user_id": id }),
650        ))
651        .await;
652        Ok(())
653    }
654
655    /// Set a user's active status and emit `user.blocked` or `user.unblocked`.
656    pub async fn update_user_active(&self, id: UserId, is_active: bool) -> Result<(), AuthError> {
657        self.db().update_user_active(id, is_active).await?;
658        let event_type = if is_active {
659            "user.unblocked"
660        } else {
661            "user.blocked"
662        };
663        self.emit_event(AuthEvent::new(
664            event_type,
665            Some(id),
666            serde_json::json!({ "user_id": id }),
667        ))
668        .await;
669        Ok(())
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::handle::{AllowThem, AllowThemBuilder};
677
678    async fn setup() -> AllowThem {
679        AllowThemBuilder::new("sqlite::memory:")
680            .cookie_secure(false)
681            .build()
682            .await
683            .unwrap()
684    }
685
686    async fn make_user(db: &Db, tag: u32) -> crate::types::User {
687        let email = Email::new(format!("user{tag}@example.com")).unwrap();
688        db.create_user(email, "pw123456", None, None).await.unwrap()
689    }
690
691    #[tokio::test]
692    async fn user_cursor_encode_decode_roundtrip() {
693        let ath = setup().await;
694        let db = ath.db();
695        let user = make_user(db, 1).await;
696        let entries = db.list_users_paginated(10, None).await.unwrap();
697        assert_eq!(entries.len(), 1);
698        let cursor = UserCursor::from_entry(&entries[0]);
699        let encoded = cursor.encode();
700        let decoded = UserCursor::decode(&encoded).unwrap();
701        assert_eq!(decoded.id, user.id);
702    }
703
704    #[tokio::test]
705    async fn list_users_paginated_returns_first_page() {
706        let ath = setup().await;
707        let db = ath.db();
708        for i in 0..5 {
709            make_user(db, i).await;
710        }
711        let page = db.list_users_paginated(3, None).await.unwrap();
712        assert_eq!(page.len(), 3);
713    }
714
715    #[tokio::test]
716    async fn set_email_verified_toggles_flag() {
717        let ath = setup().await;
718        let db = ath.db();
719        let user = make_user(db, 99).await;
720        assert!(!user.email_verified);
721
722        db.set_email_verified(user.id, true).await.unwrap();
723        let after = db.get_user(user.id).await.unwrap();
724        assert!(after.email_verified);
725
726        db.set_email_verified(user.id, false).await.unwrap();
727        let after = db.get_user(user.id).await.unwrap();
728        assert!(!after.email_verified);
729    }
730
731    #[tokio::test]
732    async fn set_email_verified_unknown_id_returns_not_found() {
733        let ath = setup().await;
734        let db = ath.db();
735        let err = db
736            .set_email_verified(UserId::new(), true)
737            .await
738            .unwrap_err();
739        assert!(matches!(err, AuthError::NotFound));
740    }
741
742    #[tokio::test]
743    async fn list_users_paginated_cursor_advances() {
744        let ath = setup().await;
745        let db = ath.db();
746        for i in 0..5 {
747            make_user(db, i + 10).await;
748        }
749        let page1 = db.list_users_paginated(3, None).await.unwrap();
750        assert_eq!(page1.len(), 3);
751        let cursor = UserCursor::from_entry(page1.last().unwrap());
752        let page2 = db.list_users_paginated(3, Some(&cursor)).await.unwrap();
753        assert_eq!(page2.len(), 2);
754        assert!(!page2.iter().any(|u| page1.iter().any(|v| v.id == u.id)));
755    }
756
757    fn unfiltered(limit: u32) -> SearchUsersParams<'static> {
758        SearchUsersParams {
759            query: None,
760            is_active: None,
761            has_mfa: None,
762            email_verified: None,
763            limit,
764            offset: 0,
765        }
766    }
767
768    #[tokio::test]
769    async fn search_users_filter_email_verified_true() {
770        let ath = setup().await;
771        let db = ath.db();
772        let u1 = make_user(db, 1).await;
773        let _u2 = make_user(db, 2).await;
774        db.set_email_verified(u1.id, true).await.unwrap();
775
776        let result = db
777            .search_users(SearchUsersParams {
778                email_verified: Some(true),
779                ..unfiltered(10)
780            })
781            .await
782            .unwrap();
783        assert_eq!(result.total, 1);
784        assert_eq!(result.users.len(), 1);
785        assert_eq!(result.users[0].id, u1.id);
786    }
787
788    #[tokio::test]
789    async fn search_users_filter_email_verified_false() {
790        let ath = setup().await;
791        let db = ath.db();
792        let u1 = make_user(db, 1).await;
793        let u2 = make_user(db, 2).await;
794        db.set_email_verified(u1.id, true).await.unwrap();
795
796        let result = db
797            .search_users(SearchUsersParams {
798                email_verified: Some(false),
799                ..unfiltered(10)
800            })
801            .await
802            .unwrap();
803        assert_eq!(result.total, 1);
804        assert_eq!(result.users[0].id, u2.id);
805    }
806
807    #[tokio::test]
808    async fn search_users_filter_email_verified_none_includes_both() {
809        let ath = setup().await;
810        let db = ath.db();
811        let u1 = make_user(db, 1).await;
812        let _u2 = make_user(db, 2).await;
813        db.set_email_verified(u1.id, true).await.unwrap();
814
815        let result = db.search_users(unfiltered(10)).await.unwrap();
816        assert_eq!(result.total, 2);
817        assert_eq!(result.users.len(), 2);
818    }
819
820    // count_users tests (99c.6 Step 2)
821
822    #[tokio::test]
823    async fn count_users_zero_on_empty_db() {
824        let ath = setup().await;
825        let n = ath.db().count_users().await.expect("count_users");
826        assert_eq!(n, 0);
827    }
828
829    #[tokio::test]
830    async fn count_users_after_create() {
831        let ath = setup().await;
832        let db = ath.db();
833        make_user(db, 1).await;
834        make_user(db, 2).await;
835        make_user(db, 3).await;
836        let n = db.count_users().await.expect("count_users");
837        assert_eq!(n, 3);
838    }
839}