Skip to main content

starpod_auth/
lib.rs

1//! Database-backed user authentication for Starpod.
2//!
3//! This crate provides per-user API keys (argon2id-hashed), Telegram account
4//! linking, role-based access control (admin/user), in-memory rate limiting,
5//! and an audit log — all backed by a shared SQLite database (`core.db`).
6//!
7//! ## Key concepts
8//!
9//! - **Users** are identified by UUID and can be admin or regular users.
10//! - **API keys** follow the format `sp_live_` + 40 hex chars. Only the
11//!   argon2id hash is stored; the plaintext is returned once at creation.
12//! - **Telegram links** map a Telegram user ID to a database user for
13//!   bot authentication.
14//! - **Bootstrap** creates the first admin user on an empty database and
15//!   optionally imports a legacy `STARPOD_API_KEY` for backward compatibility.
16//!
17//! ## Usage
18//!
19//! ```no_run
20//! # async fn example() -> starpod_core::Result<()> {
21//! use starpod_auth::{AuthStore, Role};
22//! use starpod_db::CoreDb;
23//!
24//! let db = CoreDb::new(std::path::Path::new(".starpod/db")).await?;
25//! let store = AuthStore::from_pool(db.pool().clone());
26//! let user = store.create_user(None, Some("Alice"), Role::User).await?;
27//! let key = store.create_api_key(&user.id, Some("web")).await?;
28//! // key.key is the plaintext — show it once, then discard
29//!
30//! let authed = store.authenticate_api_key(&key.key).await?;
31//! assert!(authed.is_some());
32//! # Ok(())
33//! # }
34//! ```
35
36pub mod api_key;
37pub mod rate_limit;
38pub mod types;
39
40use chrono::Utc;
41use sqlx::{Row, SqlitePool};
42use tracing::{debug, info};
43use uuid::Uuid;
44
45use starpod_core::{Result, StarpodError};
46
47pub use rate_limit::RateLimiter;
48pub use types::*;
49
50/// Database-backed authentication store.
51///
52/// Wraps a SQLite connection pool and provides methods for user management,
53/// API key authentication, Telegram linking, and audit logging.
54///
55/// Thread-safe: can be wrapped in `Arc` and shared across async tasks.
56pub struct AuthStore {
57    pool: SqlitePool,
58}
59
60impl AuthStore {
61    /// Create an AuthStore from a shared pool.
62    ///
63    /// The pool should already have migrations applied (via `CoreDb`).
64    pub fn from_pool(pool: SqlitePool) -> Self {
65        Self { pool }
66    }
67
68    // ── User CRUD ────────────────────────────────────────────────────────
69
70    /// Create a new user with a random UUID.
71    ///
72    /// Both `email` and `display_name` are optional. If `email` is provided,
73    /// it must be unique across all users (enforced by the database).
74    pub async fn create_user(
75        &self,
76        email: Option<&str>,
77        display_name: Option<&str>,
78        role: Role,
79    ) -> Result<User> {
80        let id = Uuid::new_v4().to_string();
81        let now = Utc::now();
82        let now_str = now.to_rfc3339();
83        let role_str = role.as_str();
84
85        sqlx::query(
86            "INSERT INTO users (id, email, display_name, role, is_active, created_at, updated_at) \
87             VALUES (?, ?, ?, ?, 1, ?, ?)",
88        )
89        .bind(&id)
90        .bind(email)
91        .bind(display_name)
92        .bind(role_str)
93        .bind(&now_str)
94        .bind(&now_str)
95        .execute(&self.pool)
96        .await
97        .map_err(|e| StarpodError::Auth(format!("Failed to create user: {}", e)))?;
98
99        debug!(user_id = %id, role = %role_str, "User created");
100
101        Ok(User {
102            id,
103            email: email.map(String::from),
104            display_name: display_name.map(String::from),
105            role,
106            is_active: true,
107            filesystem_enabled: false,
108            created_at: now,
109            updated_at: now,
110        })
111    }
112
113    /// Get a user by ID.
114    pub async fn get_user(&self, id: &str) -> Result<Option<User>> {
115        let row = sqlx::query(
116            "SELECT id, email, display_name, role, is_active, filesystem_enabled, created_at, updated_at FROM users WHERE id = ?"
117        )
118        .bind(id)
119        .fetch_optional(&self.pool)
120        .await
121        .map_err(|e| StarpodError::Auth(format!("Failed to get user: {}", e)))?;
122
123        Ok(row.map(|r| row_to_user(&r)))
124    }
125
126    /// List all users.
127    pub async fn list_users(&self) -> Result<Vec<User>> {
128        let rows = sqlx::query(
129            "SELECT id, email, display_name, role, is_active, filesystem_enabled, created_at, updated_at \
130             FROM users ORDER BY created_at ASC"
131        )
132        .fetch_all(&self.pool)
133        .await
134        .map_err(|e| StarpodError::Auth(format!("Failed to list users: {}", e)))?;
135
136        Ok(rows.iter().map(row_to_user).collect())
137    }
138
139    /// Update a user's fields. Only non-`None` values are applied (COALESCE).
140    ///
141    /// Pass `role: Some(Role::Admin)` to promote a user, or `role: None` to
142    /// leave the role unchanged.
143    pub async fn update_user(
144        &self,
145        id: &str,
146        email: Option<&str>,
147        display_name: Option<&str>,
148        role: Option<Role>,
149        filesystem_enabled: Option<bool>,
150    ) -> Result<()> {
151        let now = Utc::now().to_rfc3339();
152
153        let role_clause = role
154            .map(|r| format!(", role = '{}'", r.as_str()))
155            .unwrap_or_default();
156        let fs_clause = filesystem_enabled
157            .map(|v| format!(", filesystem_enabled = {}", v as i32))
158            .unwrap_or_default();
159
160        let sql = format!(
161            "UPDATE users SET email = COALESCE(?, email), display_name = COALESCE(?, display_name){}{}, \
162             updated_at = ? WHERE id = ?",
163            role_clause, fs_clause,
164        );
165
166        sqlx::query(&sql)
167            .bind(email)
168            .bind(display_name)
169            .bind(&now)
170            .bind(id)
171            .execute(&self.pool)
172            .await
173            .map_err(|e| StarpodError::Auth(format!("Failed to update user: {}", e)))?;
174
175        Ok(())
176    }
177
178    /// Deactivate a user (soft-delete). Sets `is_active = false`.
179    ///
180    /// Deactivated users cannot authenticate via API keys or Telegram.
181    /// Their data is preserved — use this instead of hard-deleting.
182    pub async fn deactivate_user(&self, id: &str) -> Result<()> {
183        let now = Utc::now().to_rfc3339();
184        sqlx::query("UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?")
185            .bind(&now)
186            .bind(id)
187            .execute(&self.pool)
188            .await
189            .map_err(|e| StarpodError::Auth(format!("Failed to deactivate user: {}", e)))?;
190        Ok(())
191    }
192
193    /// Reactivate a previously deactivated user. Sets `is_active = true`.
194    pub async fn activate_user(&self, id: &str) -> Result<()> {
195        let now = Utc::now().to_rfc3339();
196        sqlx::query("UPDATE users SET is_active = 1, updated_at = ? WHERE id = ?")
197            .bind(&now)
198            .bind(id)
199            .execute(&self.pool)
200            .await
201            .map_err(|e| StarpodError::Auth(format!("Failed to activate user: {}", e)))?;
202        Ok(())
203    }
204
205    // ── API Keys ─────────────────────────────────────────────────────────
206
207    /// Create a new API key for a user. Returns the full key (shown only once).
208    pub async fn create_api_key(
209        &self,
210        user_id: &str,
211        label: Option<&str>,
212    ) -> Result<ApiKeyCreated> {
213        let key = api_key::generate_key();
214        let prefix = api_key::extract_prefix(&key)
215            .ok_or_else(|| StarpodError::Auth("Failed to extract key prefix".into()))?
216            .to_string();
217        let hash = api_key::hash_key(&key)
218            .map_err(|e| StarpodError::Auth(format!("Failed to hash key: {}", e)))?;
219
220        let id = Uuid::new_v4().to_string();
221        let now = Utc::now();
222        let now_str = now.to_rfc3339();
223
224        sqlx::query(
225            "INSERT INTO api_keys (id, user_id, prefix, key_hash, label, created_at) \
226             VALUES (?, ?, ?, ?, ?, ?)",
227        )
228        .bind(&id)
229        .bind(user_id)
230        .bind(&prefix)
231        .bind(&hash)
232        .bind(label)
233        .bind(&now_str)
234        .execute(&self.pool)
235        .await
236        .map_err(|e| StarpodError::Auth(format!("Failed to create API key: {}", e)))?;
237
238        debug!(user_id = %user_id, prefix = %prefix, "API key created");
239
240        Ok(ApiKeyCreated {
241            meta: ApiKeyMeta {
242                id,
243                user_id: user_id.to_string(),
244                prefix,
245                label: label.map(String::from),
246                expires_at: None,
247                revoked_at: None,
248                last_used_at: None,
249                created_at: now,
250            },
251            key,
252        })
253    }
254
255    /// Import an existing plaintext key as a user's API key (for backward-compat bootstrap).
256    pub async fn import_api_key(
257        &self,
258        user_id: &str,
259        plaintext_key: &str,
260        label: Option<&str>,
261    ) -> Result<ApiKeyMeta> {
262        let prefix = api_key::extract_prefix(plaintext_key)
263            .unwrap_or_else(|| &plaintext_key[..plaintext_key.len().min(8)])
264            .to_string();
265
266        let hash = api_key::hash_key(plaintext_key)
267            .map_err(|e| StarpodError::Auth(format!("Failed to hash key: {}", e)))?;
268
269        let id = Uuid::new_v4().to_string();
270        let now = Utc::now();
271        let now_str = now.to_rfc3339();
272
273        sqlx::query(
274            "INSERT INTO api_keys (id, user_id, prefix, key_hash, label, created_at) \
275             VALUES (?, ?, ?, ?, ?, ?)",
276        )
277        .bind(&id)
278        .bind(user_id)
279        .bind(&prefix)
280        .bind(&hash)
281        .bind(label)
282        .bind(&now_str)
283        .execute(&self.pool)
284        .await
285        .map_err(|e| StarpodError::Auth(format!("Failed to import API key: {}", e)))?;
286
287        info!(user_id = %user_id, prefix = %prefix, "API key imported");
288
289        Ok(ApiKeyMeta {
290            id,
291            user_id: user_id.to_string(),
292            prefix,
293            label: label.map(String::from),
294            expires_at: None,
295            revoked_at: None,
296            last_used_at: None,
297            created_at: now,
298        })
299    }
300
301    /// Authenticate a request by API key. Returns the user if valid.
302    pub async fn authenticate_api_key(&self, key: &str) -> Result<Option<User>> {
303        // Extract prefix: sp_live_ keys use the standard prefix, others use first 8 chars
304        let prefix = api_key::extract_prefix(key).unwrap_or_else(|| &key[..key.len().min(8)]);
305
306        let candidates = sqlx::query(
307                "SELECT ak.id AS ak_id, ak.key_hash, u.id, u.email, u.display_name, u.role, u.is_active, \
308                 u.filesystem_enabled, u.created_at, u.updated_at \
309                 FROM api_keys ak JOIN users u ON ak.user_id = u.id \
310                 WHERE ak.prefix = ? AND ak.revoked_at IS NULL AND u.is_active = 1"
311            )
312            .bind(prefix)
313            .fetch_all(&self.pool)
314            .await
315            .map_err(|e| StarpodError::Auth(format!("Auth query failed: {}", e)))?;
316
317        for row in &candidates {
318            let hash: String = row.get("key_hash");
319            if api_key::verify_key(key, &hash) {
320                let ak_id: String = row.get("ak_id");
321                // Update last_used_at
322                let now = Utc::now().to_rfc3339();
323                let _ = sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?")
324                    .bind(&now)
325                    .bind(&ak_id)
326                    .execute(&self.pool)
327                    .await;
328
329                return Ok(Some(User {
330                    id: row.get("id"),
331                    email: row.get("email"),
332                    display_name: row.get("display_name"),
333                    role: Role::from_str(row.get::<&str, _>("role")).unwrap_or(Role::User),
334                    is_active: row.get::<bool, _>("is_active"),
335                    filesystem_enabled: row.get::<bool, _>("filesystem_enabled"),
336                    created_at: parse_dt(row.get("created_at")),
337                    updated_at: parse_dt(row.get("updated_at")),
338                }));
339            }
340        }
341
342        Ok(None)
343    }
344
345    /// List API keys for a user (metadata only, no hashes).
346    pub async fn list_api_keys(&self, user_id: &str) -> Result<Vec<ApiKeyMeta>> {
347        let rows = sqlx::query(
348            "SELECT id, user_id, prefix, label, expires_at, revoked_at, last_used_at, created_at \
349             FROM api_keys WHERE user_id = ? ORDER BY created_at DESC",
350        )
351        .bind(user_id)
352        .fetch_all(&self.pool)
353        .await
354        .map_err(|e| StarpodError::Auth(format!("Failed to list API keys: {}", e)))?;
355
356        Ok(rows.iter().map(row_to_api_key_meta).collect())
357    }
358
359    /// Revoke an API key by its database ID.
360    ///
361    /// Revoked keys immediately fail authentication. The key record is
362    /// preserved for audit purposes.
363    pub async fn revoke_api_key(&self, key_id: &str) -> Result<()> {
364        let now = Utc::now().to_rfc3339();
365        sqlx::query("UPDATE api_keys SET revoked_at = ? WHERE id = ?")
366            .bind(&now)
367            .bind(key_id)
368            .execute(&self.pool)
369            .await
370            .map_err(|e| StarpodError::Auth(format!("Failed to revoke API key: {}", e)))?;
371        Ok(())
372    }
373
374    // ── Telegram Links ───────────────────────────────────────────────────
375
376    /// Link a Telegram account to a user.
377    ///
378    /// Accepts either a `telegram_id`, a `username`, or both. At least one
379    /// must be provided. When only a username is given, the `telegram_id` is
380    /// back-filled automatically when the user first messages the bot (see
381    /// [`authenticate_telegram`](Self::authenticate_telegram)).
382    ///
383    /// Each user can have at most one Telegram link. Calling this method
384    /// replaces any existing link for the same `user_id`, `telegram_id`, or
385    /// `username` to maintain uniqueness across all three dimensions.
386    pub async fn link_telegram(
387        &self,
388        user_id: &str,
389        telegram_id: Option<i64>,
390        username: Option<&str>,
391    ) -> Result<TelegramLink> {
392        let now = Utc::now();
393        let now_str = now.to_rfc3339();
394
395        // Remove any existing link for this user (one link per user)
396        sqlx::query("DELETE FROM telegram_links WHERE user_id = ?")
397            .bind(user_id)
398            .execute(&self.pool)
399            .await
400            .map_err(|e| StarpodError::Auth(format!("Failed to clear old Telegram link: {}", e)))?;
401
402        // Also remove any existing link with the same telegram_id or username
403        // so the new link doesn't violate UNIQUE constraints
404        if let Some(tid) = telegram_id {
405            sqlx::query("DELETE FROM telegram_links WHERE telegram_id = ?")
406                .bind(tid)
407                .execute(&self.pool)
408                .await
409                .map_err(|e| {
410                    StarpodError::Auth(format!("Failed to clear old Telegram ID link: {}", e))
411                })?;
412        }
413        if let Some(uname) = username {
414            sqlx::query("DELETE FROM telegram_links WHERE username = ?")
415                .bind(uname)
416                .execute(&self.pool)
417                .await
418                .map_err(|e| {
419                    StarpodError::Auth(format!("Failed to clear old username link: {}", e))
420                })?;
421        }
422
423        sqlx::query(
424            "INSERT INTO telegram_links (telegram_id, user_id, username, linked_at) \
425             VALUES (?, ?, ?, ?)",
426        )
427        .bind(telegram_id)
428        .bind(user_id)
429        .bind(username)
430        .bind(&now_str)
431        .execute(&self.pool)
432        .await
433        .map_err(|e| StarpodError::Auth(format!("Failed to link Telegram: {}", e)))?;
434
435        debug!(user_id = %user_id, telegram_id = ?telegram_id, username = ?username, "Telegram account linked");
436
437        Ok(TelegramLink {
438            telegram_id,
439            user_id: user_id.to_string(),
440            username: username.map(String::from),
441            linked_at: now,
442        })
443    }
444
445    /// Back-fill the telegram_id on an existing username-only link.
446    ///
447    /// Called by the bot when a user with a matching username sends their
448    /// first message and the link was created without a numeric ID.
449    pub async fn backfill_telegram_id(&self, username: &str, telegram_id: i64) -> Result<()> {
450        sqlx::query(
451            "UPDATE telegram_links SET telegram_id = ? WHERE username = ? AND telegram_id IS NULL",
452        )
453        .bind(telegram_id)
454        .bind(username)
455        .execute(&self.pool)
456        .await
457        .map_err(|e| StarpodError::Auth(format!("Failed to backfill Telegram ID: {}", e)))?;
458        Ok(())
459    }
460
461    /// Unlink a Telegram account.
462    pub async fn unlink_telegram(&self, telegram_id: i64) -> Result<()> {
463        sqlx::query("DELETE FROM telegram_links WHERE telegram_id = ?")
464            .bind(telegram_id)
465            .execute(&self.pool)
466            .await
467            .map_err(|e| StarpodError::Auth(format!("Failed to unlink Telegram: {}", e)))?;
468        Ok(())
469    }
470
471    /// Authenticate a Telegram user by their numeric ID, falling back to username.
472    ///
473    /// Resolution order:
474    /// 1. Look up by `telegram_id` (exact match).
475    /// 2. If no match and `username` is provided, look up by `username`.
476    /// 3. If matched by username and the link has no `telegram_id` yet, the
477    ///    numeric ID is back-filled so subsequent lookups succeed by ID alone.
478    ///
479    /// Returns `None` if the user is not linked or is deactivated.
480    pub async fn authenticate_telegram(
481        &self,
482        telegram_id: i64,
483        username: Option<&str>,
484    ) -> Result<Option<User>> {
485        // Try by ID first
486        let row = sqlx::query(
487            "SELECT u.id, u.email, u.display_name, u.role, u.is_active, u.filesystem_enabled, u.created_at, u.updated_at \
488             FROM telegram_links tl JOIN users u ON tl.user_id = u.id \
489             WHERE tl.telegram_id = ? AND u.is_active = 1"
490        )
491        .bind(telegram_id)
492        .fetch_optional(&self.pool)
493        .await
494        .map_err(|e| StarpodError::Auth(format!("Telegram auth query failed: {}", e)))?;
495
496        if let Some(r) = row {
497            return Ok(Some(row_to_user(&r)));
498        }
499
500        // Fall back to username match (for username-only links)
501        if let Some(uname) = username {
502            let row = sqlx::query(
503                "SELECT u.id, u.email, u.display_name, u.role, u.is_active, u.filesystem_enabled, u.created_at, u.updated_at \
504                 FROM telegram_links tl JOIN users u ON tl.user_id = u.id \
505                 WHERE tl.username = ? AND u.is_active = 1"
506            )
507            .bind(uname)
508            .fetch_optional(&self.pool)
509            .await
510            .map_err(|e| StarpodError::Auth(format!("Telegram username auth query failed: {}", e)))?;
511
512            if let Some(r) = row {
513                // Back-fill the telegram_id now that we know it
514                self.backfill_telegram_id(uname, telegram_id).await?;
515                return Ok(Some(row_to_user(&r)));
516            }
517        }
518
519        Ok(None)
520    }
521
522    /// Get the Telegram link for a specific user.
523    pub async fn get_telegram_link_for_user(&self, user_id: &str) -> Result<Option<TelegramLink>> {
524        let row = sqlx::query(
525            "SELECT telegram_id, user_id, username, linked_at FROM telegram_links WHERE user_id = ?"
526        )
527        .bind(user_id)
528        .fetch_optional(&self.pool)
529        .await
530        .map_err(|e| StarpodError::Auth(format!("Failed to get Telegram link: {}", e)))?;
531
532        Ok(row.map(|r| TelegramLink {
533            telegram_id: r.get("telegram_id"),
534            user_id: r.get("user_id"),
535            username: r.get("username"),
536            linked_at: parse_dt(r.get("linked_at")),
537        }))
538    }
539
540    /// Unlink a Telegram account by user ID.
541    pub async fn unlink_telegram_by_user(&self, user_id: &str) -> Result<()> {
542        sqlx::query("DELETE FROM telegram_links WHERE user_id = ?")
543            .bind(user_id)
544            .execute(&self.pool)
545            .await
546            .map_err(|e| StarpodError::Auth(format!("Failed to unlink Telegram: {}", e)))?;
547        Ok(())
548    }
549
550    /// List all Telegram links.
551    pub async fn list_telegram_links(&self) -> Result<Vec<TelegramLink>> {
552        let rows = sqlx::query(
553            "SELECT telegram_id, user_id, username, linked_at FROM telegram_links ORDER BY linked_at DESC"
554        )
555        .fetch_all(&self.pool)
556        .await
557        .map_err(|e| StarpodError::Auth(format!("Failed to list Telegram links: {}", e)))?;
558
559        Ok(rows
560            .iter()
561            .map(|r| TelegramLink {
562                telegram_id: r.get("telegram_id"),
563                user_id: r.get("user_id"),
564                username: r.get("username"),
565                linked_at: parse_dt(r.get("linked_at")),
566            })
567            .collect())
568    }
569
570    // ── Audit Log ────────────────────────────────────────────────────────
571
572    /// Log an authentication event to the audit table.
573    ///
574    /// Use `user_id: None` when the user could not be identified (e.g. a
575    /// failed auth attempt with an unknown key).
576    pub async fn log_event(
577        &self,
578        user_id: Option<&str>,
579        event_type: &str,
580        detail: Option<&str>,
581        ip_address: Option<&str>,
582    ) -> Result<()> {
583        let now = Utc::now().to_rfc3339();
584        sqlx::query(
585            "INSERT INTO auth_audit_log (user_id, event_type, detail, ip_address, created_at) \
586             VALUES (?, ?, ?, ?, ?)",
587        )
588        .bind(user_id)
589        .bind(event_type)
590        .bind(detail)
591        .bind(ip_address)
592        .bind(&now)
593        .execute(&self.pool)
594        .await
595        .map_err(|e| StarpodError::Auth(format!("Failed to log event: {}", e)))?;
596        Ok(())
597    }
598
599    /// Get recent audit log entries, most recent first.
600    pub async fn recent_audit(&self, limit: usize) -> Result<Vec<AuditEntry>> {
601        let rows = sqlx::query(
602            "SELECT id, user_id, event_type, detail, ip_address, created_at \
603             FROM auth_audit_log ORDER BY created_at DESC LIMIT ?",
604        )
605        .bind(limit as i64)
606        .fetch_all(&self.pool)
607        .await
608        .map_err(|e| StarpodError::Auth(format!("Failed to get audit log: {}", e)))?;
609
610        Ok(rows
611            .iter()
612            .map(|r| AuditEntry {
613                id: r.get("id"),
614                user_id: r.get("user_id"),
615                event_type: r.get("event_type"),
616                detail: r.get("detail"),
617                ip_address: r.get("ip_address"),
618                created_at: parse_dt(r.get("created_at")),
619            })
620            .collect())
621    }
622
623    // ── Bootstrap ────────────────────────────────────────────────────────
624
625    /// Bootstrap the admin user on first startup.
626    ///
627    /// If no users exist, creates an admin user. If `existing_api_key` is provided,
628    /// imports it as the admin's key (backward compat). Otherwise generates a new key.
629    ///
630    /// Returns the admin user and the API key (plaintext, for logging).
631    pub async fn bootstrap_admin(
632        &self,
633        existing_api_key: Option<&str>,
634    ) -> Result<Option<(User, String)>> {
635        // Check if any users exist already
636        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
637            .fetch_one(&self.pool)
638            .await
639            .map_err(|e| StarpodError::Auth(format!("Count query failed: {}", e)))?;
640
641        if count > 0 {
642            return Ok(None); // Already bootstrapped
643        }
644
645        let admin = self.create_user(None, Some("Admin"), Role::Admin).await?;
646        // Enable filesystem access for the bootstrap admin
647        self.update_user(&admin.id, None, None, None, Some(true))
648            .await?;
649        let admin = self.get_user(&admin.id).await?.unwrap_or(admin);
650
651        let key_str = if let Some(existing) = existing_api_key {
652            self.import_api_key(&admin.id, existing, Some("Imported from STARPOD_API_KEY"))
653                .await?;
654            info!("Imported existing STARPOD_API_KEY as admin API key");
655            existing.to_string()
656        } else {
657            let created = self
658                .create_api_key(&admin.id, Some("Auto-generated admin key"))
659                .await?;
660            info!(key = %created.key, "Generated new admin API key — save this!");
661            created.key
662        };
663
664        self.log_event(
665            Some(&admin.id),
666            "bootstrap",
667            Some("Admin user created"),
668            None,
669        )
670        .await?;
671
672        Ok(Some((admin, key_str)))
673    }
674
675    /// Check if any users exist in the database.
676    ///
677    /// Used by the gateway to decide whether to enforce authentication:
678    /// when `false` (fresh install), all requests are allowed.
679    pub async fn has_users(&self) -> Result<bool> {
680        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
681            .fetch_one(&self.pool)
682            .await
683            .map_err(|e| StarpodError::Auth(format!("Count query failed: {}", e)))?;
684        Ok(count > 0)
685    }
686}
687
688// ── Row converters ───────────────────────────────────────────────────────
689
690fn row_to_user(row: &sqlx::sqlite::SqliteRow) -> User {
691    User {
692        id: row.get("id"),
693        email: row.get("email"),
694        display_name: row.get("display_name"),
695        role: Role::from_str(row.get::<&str, _>("role")).unwrap_or(Role::User),
696        is_active: row.get::<bool, _>("is_active"),
697        filesystem_enabled: row.get::<bool, _>("filesystem_enabled"),
698        created_at: parse_dt(row.get("created_at")),
699        updated_at: parse_dt(row.get("updated_at")),
700    }
701}
702
703fn row_to_api_key_meta(row: &sqlx::sqlite::SqliteRow) -> ApiKeyMeta {
704    ApiKeyMeta {
705        id: row.get("id"),
706        user_id: row.get("user_id"),
707        prefix: row.get("prefix"),
708        label: row.get("label"),
709        expires_at: row
710            .get::<Option<String>, _>("expires_at")
711            .and_then(|s| parse_dt_opt(&s)),
712        revoked_at: row
713            .get::<Option<String>, _>("revoked_at")
714            .and_then(|s| parse_dt_opt(&s)),
715        last_used_at: row
716            .get::<Option<String>, _>("last_used_at")
717            .and_then(|s| parse_dt_opt(&s)),
718        created_at: parse_dt(row.get("created_at")),
719    }
720}
721
722fn parse_dt(s: &str) -> chrono::DateTime<Utc> {
723    chrono::DateTime::parse_from_rfc3339(s)
724        .map(|dt| dt.with_timezone(&Utc))
725        .unwrap_or_else(|_| Utc::now())
726}
727
728fn parse_dt_opt(s: &str) -> Option<chrono::DateTime<Utc>> {
729    chrono::DateTime::parse_from_rfc3339(s)
730        .map(|dt| dt.with_timezone(&Utc))
731        .ok()
732}
733
734// ── Tests ────────────────────────────────────────────────────────────────
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    async fn test_store() -> AuthStore {
741        let db = starpod_db::CoreDb::in_memory().await.unwrap();
742        AuthStore::from_pool(db.pool().clone())
743    }
744
745    #[tokio::test]
746    async fn create_and_get_user() {
747        let store = test_store().await;
748        let user = store
749            .create_user(Some("test@example.com"), Some("Test"), Role::User)
750            .await
751            .unwrap();
752        assert_eq!(user.role, Role::User);
753        assert!(user.is_active);
754
755        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
756        assert_eq!(fetched.id, user.id);
757        assert_eq!(fetched.email.as_deref(), Some("test@example.com"));
758    }
759
760    #[tokio::test]
761    async fn list_users() {
762        let store = test_store().await;
763        store
764            .create_user(None, Some("A"), Role::Admin)
765            .await
766            .unwrap();
767        store
768            .create_user(None, Some("B"), Role::User)
769            .await
770            .unwrap();
771
772        let users = store.list_users().await.unwrap();
773        assert_eq!(users.len(), 2);
774    }
775
776    #[tokio::test]
777    async fn update_user() {
778        let store = test_store().await;
779        let user = store
780            .create_user(None, Some("Old"), Role::User)
781            .await
782            .unwrap();
783        store
784            .update_user(&user.id, Some("new@example.com"), Some("New"), None, None)
785            .await
786            .unwrap();
787
788        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
789        assert_eq!(fetched.email.as_deref(), Some("new@example.com"));
790        assert_eq!(fetched.display_name.as_deref(), Some("New"));
791    }
792
793    #[tokio::test]
794    async fn deactivate_user() {
795        let store = test_store().await;
796        let user = store.create_user(None, None, Role::User).await.unwrap();
797        store.deactivate_user(&user.id).await.unwrap();
798
799        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
800        assert!(!fetched.is_active);
801    }
802
803    #[tokio::test]
804    async fn api_key_create_and_authenticate() {
805        let store = test_store().await;
806        let user = store.create_user(None, None, Role::User).await.unwrap();
807        let created = store
808            .create_api_key(&user.id, Some("test key"))
809            .await
810            .unwrap();
811
812        assert!(created.key.starts_with("sp_live_"));
813        assert_eq!(created.meta.label.as_deref(), Some("test key"));
814
815        let authed = store
816            .authenticate_api_key(&created.key)
817            .await
818            .unwrap()
819            .unwrap();
820        assert_eq!(authed.id, user.id);
821    }
822
823    #[tokio::test]
824    async fn api_key_wrong_key_fails() {
825        let store = test_store().await;
826        let user = store.create_user(None, None, Role::User).await.unwrap();
827        store.create_api_key(&user.id, None).await.unwrap();
828
829        let result = store
830            .authenticate_api_key("sp_live_0000000000000000000000000000000000000000")
831            .await
832            .unwrap();
833        assert!(result.is_none());
834    }
835
836    #[tokio::test]
837    async fn revoked_key_fails_auth() {
838        let store = test_store().await;
839        let user = store.create_user(None, None, Role::User).await.unwrap();
840        let created = store.create_api_key(&user.id, None).await.unwrap();
841
842        store.revoke_api_key(&created.meta.id).await.unwrap();
843
844        let result = store.authenticate_api_key(&created.key).await.unwrap();
845        assert!(result.is_none());
846    }
847
848    #[tokio::test]
849    async fn deactivated_user_fails_auth() {
850        let store = test_store().await;
851        let user = store.create_user(None, None, Role::User).await.unwrap();
852        let created = store.create_api_key(&user.id, None).await.unwrap();
853
854        store.deactivate_user(&user.id).await.unwrap();
855
856        let result = store.authenticate_api_key(&created.key).await.unwrap();
857        assert!(result.is_none());
858    }
859
860    #[tokio::test]
861    async fn list_api_keys() {
862        let store = test_store().await;
863        let user = store.create_user(None, None, Role::User).await.unwrap();
864        store.create_api_key(&user.id, Some("key1")).await.unwrap();
865        store.create_api_key(&user.id, Some("key2")).await.unwrap();
866
867        let keys = store.list_api_keys(&user.id).await.unwrap();
868        assert_eq!(keys.len(), 2);
869    }
870
871    #[tokio::test]
872    async fn telegram_link_and_auth() {
873        let store = test_store().await;
874        let user = store.create_user(None, None, Role::User).await.unwrap();
875        store
876            .link_telegram(&user.id, Some(123456789), Some("alice"))
877            .await
878            .unwrap();
879
880        let authed = store
881            .authenticate_telegram(123456789, None)
882            .await
883            .unwrap()
884            .unwrap();
885        assert_eq!(authed.id, user.id);
886    }
887
888    #[tokio::test]
889    async fn telegram_unlinked_fails() {
890        let store = test_store().await;
891        let result = store.authenticate_telegram(999999, None).await.unwrap();
892        assert!(result.is_none());
893    }
894
895    #[tokio::test]
896    async fn telegram_unlink() {
897        let store = test_store().await;
898        let user = store.create_user(None, None, Role::User).await.unwrap();
899        store
900            .link_telegram(&user.id, Some(123), None)
901            .await
902            .unwrap();
903        store.unlink_telegram(123).await.unwrap();
904
905        let result = store.authenticate_telegram(123, None).await.unwrap();
906        assert!(result.is_none());
907    }
908
909    #[tokio::test]
910    async fn list_telegram_links() {
911        let store = test_store().await;
912        let alice = store
913            .create_user(None, Some("Alice"), Role::User)
914            .await
915            .unwrap();
916        let bob = store
917            .create_user(None, Some("Bob"), Role::User)
918            .await
919            .unwrap();
920        store
921            .link_telegram(&alice.id, Some(111), Some("alice"))
922            .await
923            .unwrap();
924        store
925            .link_telegram(&bob.id, Some(222), Some("bob"))
926            .await
927            .unwrap();
928
929        let links = store.list_telegram_links().await.unwrap();
930        assert_eq!(links.len(), 2);
931    }
932
933    #[tokio::test]
934    async fn audit_log() {
935        let store = test_store().await;
936        store
937            .log_event(
938                Some("user1"),
939                "login",
940                Some("via API key"),
941                Some("127.0.0.1"),
942            )
943            .await
944            .unwrap();
945        store
946            .log_event(None, "failed_auth", Some("invalid key"), Some("1.2.3.4"))
947            .await
948            .unwrap();
949
950        let entries = store.recent_audit(10).await.unwrap();
951        assert_eq!(entries.len(), 2);
952        assert_eq!(entries[0].event_type, "failed_auth"); // most recent first
953    }
954
955    #[tokio::test]
956    async fn bootstrap_admin_creates_user_and_key() {
957        let store = test_store().await;
958        let result = store.bootstrap_admin(None).await.unwrap();
959        assert!(result.is_some());
960
961        let (admin, key) = result.unwrap();
962        assert_eq!(admin.role, Role::Admin);
963        assert!(key.starts_with("sp_live_"));
964
965        // Second call should return None (already bootstrapped)
966        let result2 = store.bootstrap_admin(None).await.unwrap();
967        assert!(result2.is_none());
968    }
969
970    #[tokio::test]
971    async fn bootstrap_admin_with_existing_key() {
972        let store = test_store().await;
973        let legacy_key = "my-old-secret-key";
974        let result = store.bootstrap_admin(Some(legacy_key)).await.unwrap();
975        assert!(result.is_some());
976
977        let (_, returned_key) = result.unwrap();
978        assert_eq!(returned_key, legacy_key);
979
980        // Legacy key should authenticate
981        let authed = store.authenticate_api_key(legacy_key).await.unwrap();
982        assert!(authed.is_some());
983        assert_eq!(authed.unwrap().role, Role::Admin);
984    }
985
986    #[tokio::test]
987    async fn update_user_role() {
988        let store = test_store().await;
989        let user = store.create_user(None, None, Role::User).await.unwrap();
990        store
991            .update_user(&user.id, None, None, Some(Role::Admin), None)
992            .await
993            .unwrap();
994
995        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
996        assert_eq!(fetched.role, Role::Admin);
997    }
998
999    #[tokio::test]
1000    async fn has_users_empty() {
1001        let store = test_store().await;
1002        assert!(!store.has_users().await.unwrap());
1003    }
1004
1005    #[tokio::test]
1006    async fn has_users_with_user() {
1007        let store = test_store().await;
1008        store.create_user(None, None, Role::User).await.unwrap();
1009        assert!(store.has_users().await.unwrap());
1010    }
1011
1012    #[tokio::test]
1013    async fn duplicate_email_rejected() {
1014        let store = test_store().await;
1015        store
1016            .create_user(Some("dup@example.com"), None, Role::User)
1017            .await
1018            .unwrap();
1019        let result = store
1020            .create_user(Some("dup@example.com"), None, Role::User)
1021            .await;
1022        assert!(
1023            result.is_err(),
1024            "Duplicate email should be rejected by UNIQUE constraint"
1025        );
1026    }
1027
1028    #[tokio::test]
1029    async fn null_email_allows_multiple() {
1030        let store = test_store().await;
1031        store.create_user(None, None, Role::User).await.unwrap();
1032        store.create_user(None, None, Role::User).await.unwrap();
1033        let users = store.list_users().await.unwrap();
1034        assert_eq!(
1035            users.len(),
1036            2,
1037            "Multiple users with NULL email should be allowed"
1038        );
1039    }
1040
1041    #[tokio::test]
1042    async fn get_nonexistent_user() {
1043        let store = test_store().await;
1044        let result = store.get_user("nonexistent-id").await.unwrap();
1045        assert!(result.is_none());
1046    }
1047
1048    #[tokio::test]
1049    async fn authenticate_empty_key() {
1050        let store = test_store().await;
1051        let user = store.create_user(None, None, Role::User).await.unwrap();
1052        store.create_api_key(&user.id, None).await.unwrap();
1053        let result = store.authenticate_api_key("").await.unwrap();
1054        assert!(result.is_none());
1055    }
1056
1057    #[tokio::test]
1058    async fn authenticate_updates_last_used() {
1059        let store = test_store().await;
1060        let user = store.create_user(None, None, Role::User).await.unwrap();
1061        let created = store.create_api_key(&user.id, None).await.unwrap();
1062
1063        // Initially last_used_at should be None
1064        let keys = store.list_api_keys(&user.id).await.unwrap();
1065        assert!(keys[0].last_used_at.is_none());
1066
1067        // After authentication, last_used_at should be set
1068        store.authenticate_api_key(&created.key).await.unwrap();
1069        let keys = store.list_api_keys(&user.id).await.unwrap();
1070        assert!(keys[0].last_used_at.is_some());
1071    }
1072
1073    #[tokio::test]
1074    async fn multiple_keys_per_user() {
1075        let store = test_store().await;
1076        let user = store.create_user(None, None, Role::User).await.unwrap();
1077        let k1 = store.create_api_key(&user.id, Some("key1")).await.unwrap();
1078        let k2 = store.create_api_key(&user.id, Some("key2")).await.unwrap();
1079
1080        // Both keys should authenticate
1081        let u1 = store.authenticate_api_key(&k1.key).await.unwrap().unwrap();
1082        let u2 = store.authenticate_api_key(&k2.key).await.unwrap().unwrap();
1083        assert_eq!(u1.id, user.id);
1084        assert_eq!(u2.id, user.id);
1085
1086        // Revoking one should not affect the other
1087        store.revoke_api_key(&k1.meta.id).await.unwrap();
1088        assert!(store.authenticate_api_key(&k1.key).await.unwrap().is_none());
1089        assert!(store.authenticate_api_key(&k2.key).await.unwrap().is_some());
1090    }
1091
1092    #[tokio::test]
1093    async fn telegram_relink_to_different_user() {
1094        let store = test_store().await;
1095        let alice = store
1096            .create_user(None, Some("Alice"), Role::User)
1097            .await
1098            .unwrap();
1099        let bob = store
1100            .create_user(None, Some("Bob"), Role::User)
1101            .await
1102            .unwrap();
1103
1104        // Link to Alice
1105        store
1106            .link_telegram(&alice.id, Some(999), None)
1107            .await
1108            .unwrap();
1109        let authed = store
1110            .authenticate_telegram(999, None)
1111            .await
1112            .unwrap()
1113            .unwrap();
1114        assert_eq!(authed.id, alice.id);
1115
1116        // Relink same telegram_id to Bob (INSERT OR REPLACE)
1117        store.link_telegram(&bob.id, Some(999), None).await.unwrap();
1118        let authed = store
1119            .authenticate_telegram(999, None)
1120            .await
1121            .unwrap()
1122            .unwrap();
1123        assert_eq!(authed.id, bob.id, "Relink should point to the new user");
1124
1125        // Should only be one link total
1126        let links = store.list_telegram_links().await.unwrap();
1127        assert_eq!(links.len(), 1);
1128    }
1129
1130    #[tokio::test]
1131    async fn deactivated_user_telegram_auth_fails() {
1132        let store = test_store().await;
1133        let user = store.create_user(None, None, Role::User).await.unwrap();
1134        store
1135            .link_telegram(&user.id, Some(111), None)
1136            .await
1137            .unwrap();
1138
1139        store.deactivate_user(&user.id).await.unwrap();
1140
1141        let result = store.authenticate_telegram(111, None).await.unwrap();
1142        assert!(
1143            result.is_none(),
1144            "Deactivated user should not authenticate via Telegram"
1145        );
1146    }
1147
1148    #[tokio::test]
1149    async fn audit_log_entries_have_correct_fields() {
1150        let store = test_store().await;
1151        store
1152            .log_event(
1153                Some("uid"),
1154                "api_key_created",
1155                Some("label: test"),
1156                Some("10.0.0.1"),
1157            )
1158            .await
1159            .unwrap();
1160
1161        let entries = store.recent_audit(1).await.unwrap();
1162        assert_eq!(entries.len(), 1);
1163        let e = &entries[0];
1164        assert_eq!(e.user_id.as_deref(), Some("uid"));
1165        assert_eq!(e.event_type, "api_key_created");
1166        assert_eq!(e.detail.as_deref(), Some("label: test"));
1167        assert_eq!(e.ip_address.as_deref(), Some("10.0.0.1"));
1168    }
1169
1170    #[tokio::test]
1171    async fn audit_log_respects_limit() {
1172        let store = test_store().await;
1173        for i in 0..10 {
1174            store
1175                .log_event(None, &format!("event_{}", i), None, None)
1176                .await
1177                .unwrap();
1178        }
1179        let entries = store.recent_audit(3).await.unwrap();
1180        assert_eq!(entries.len(), 3);
1181    }
1182
1183    #[tokio::test]
1184    async fn get_telegram_link_for_user() {
1185        let store = test_store().await;
1186        let user = store
1187            .create_user(None, Some("Alice"), Role::User)
1188            .await
1189            .unwrap();
1190        store
1191            .link_telegram(&user.id, Some(12345), Some("alice"))
1192            .await
1193            .unwrap();
1194
1195        let link = store
1196            .get_telegram_link_for_user(&user.id)
1197            .await
1198            .unwrap()
1199            .unwrap();
1200        assert_eq!(link.telegram_id, Some(12345));
1201        assert_eq!(link.username.as_deref(), Some("alice"));
1202    }
1203
1204    #[tokio::test]
1205    async fn get_telegram_link_for_user_none() {
1206        let store = test_store().await;
1207        let user = store.create_user(None, None, Role::User).await.unwrap();
1208        let link = store.get_telegram_link_for_user(&user.id).await.unwrap();
1209        assert!(link.is_none());
1210    }
1211
1212    #[tokio::test]
1213    async fn unlink_telegram_by_user() {
1214        let store = test_store().await;
1215        let user = store.create_user(None, None, Role::User).await.unwrap();
1216        store
1217            .link_telegram(&user.id, Some(999), None)
1218            .await
1219            .unwrap();
1220
1221        store.unlink_telegram_by_user(&user.id).await.unwrap();
1222        let result = store.authenticate_telegram(999, None).await.unwrap();
1223        assert!(result.is_none());
1224    }
1225
1226    #[tokio::test]
1227    async fn telegram_link_username_only() {
1228        let store = test_store().await;
1229        let user = store
1230            .create_user(None, Some("Alice"), Role::User)
1231            .await
1232            .unwrap();
1233
1234        // Link with username only (no telegram_id)
1235        let link = store
1236            .link_telegram(&user.id, None, Some("alice"))
1237            .await
1238            .unwrap();
1239        assert_eq!(link.telegram_id, None);
1240        assert_eq!(link.username.as_deref(), Some("alice"));
1241
1242        // Verify the link is retrievable
1243        let found = store
1244            .get_telegram_link_for_user(&user.id)
1245            .await
1246            .unwrap()
1247            .unwrap();
1248        assert_eq!(found.telegram_id, None);
1249        assert_eq!(found.username.as_deref(), Some("alice"));
1250    }
1251
1252    #[tokio::test]
1253    async fn telegram_auth_by_username_with_backfill() {
1254        let store = test_store().await;
1255        let user = store
1256            .create_user(None, Some("Alice"), Role::User)
1257            .await
1258            .unwrap();
1259
1260        // Link with username only
1261        store
1262            .link_telegram(&user.id, None, Some("alice"))
1263            .await
1264            .unwrap();
1265
1266        // Auth by ID alone should fail (no telegram_id stored)
1267        let result = store.authenticate_telegram(42, None).await.unwrap();
1268        assert!(result.is_none());
1269
1270        // Auth with matching username should succeed and backfill the ID
1271        let authed = store
1272            .authenticate_telegram(42, Some("alice"))
1273            .await
1274            .unwrap()
1275            .unwrap();
1276        assert_eq!(authed.id, user.id);
1277
1278        // After backfill, auth by ID alone should now work
1279        let authed2 = store
1280            .authenticate_telegram(42, None)
1281            .await
1282            .unwrap()
1283            .unwrap();
1284        assert_eq!(authed2.id, user.id);
1285    }
1286
1287    #[tokio::test]
1288    async fn telegram_auth_username_mismatch_fails() {
1289        let store = test_store().await;
1290        let user = store.create_user(None, None, Role::User).await.unwrap();
1291
1292        // Link with username only
1293        store
1294            .link_telegram(&user.id, None, Some("alice"))
1295            .await
1296            .unwrap();
1297
1298        // Auth with wrong username should fail
1299        let result = store.authenticate_telegram(42, Some("bob")).await.unwrap();
1300        assert!(result.is_none());
1301    }
1302
1303    #[tokio::test]
1304    async fn telegram_link_replaces_same_user() {
1305        let store = test_store().await;
1306        let user = store.create_user(None, None, Role::User).await.unwrap();
1307
1308        // Link with ID
1309        store
1310            .link_telegram(&user.id, Some(111), Some("old"))
1311            .await
1312            .unwrap();
1313        // Relink same user with different ID — old link removed
1314        store
1315            .link_telegram(&user.id, Some(222), Some("new"))
1316            .await
1317            .unwrap();
1318
1319        let result = store.authenticate_telegram(111, None).await.unwrap();
1320        assert!(result.is_none(), "Old telegram_id should no longer work");
1321
1322        let authed = store
1323            .authenticate_telegram(222, None)
1324            .await
1325            .unwrap()
1326            .unwrap();
1327        assert_eq!(authed.id, user.id);
1328
1329        // Only one link should exist
1330        let links = store.list_telegram_links().await.unwrap();
1331        assert_eq!(links.len(), 1);
1332    }
1333
1334    #[tokio::test]
1335    async fn telegram_link_replaces_conflicting_username() {
1336        let store = test_store().await;
1337        let alice = store
1338            .create_user(None, Some("Alice"), Role::User)
1339            .await
1340            .unwrap();
1341        let bob = store
1342            .create_user(None, Some("Bob"), Role::User)
1343            .await
1344            .unwrap();
1345
1346        // Alice links with username "shared"
1347        store
1348            .link_telegram(&alice.id, None, Some("shared"))
1349            .await
1350            .unwrap();
1351        // Bob links with same username — Alice's link is removed
1352        store
1353            .link_telegram(&bob.id, None, Some("shared"))
1354            .await
1355            .unwrap();
1356
1357        let authed = store
1358            .authenticate_telegram(42, Some("shared"))
1359            .await
1360            .unwrap()
1361            .unwrap();
1362        assert_eq!(
1363            authed.id, bob.id,
1364            "Username 'shared' should now point to Bob"
1365        );
1366
1367        // Alice should have no link
1368        let alice_link = store.get_telegram_link_for_user(&alice.id).await.unwrap();
1369        assert!(alice_link.is_none());
1370    }
1371
1372    #[tokio::test]
1373    async fn telegram_backfill_does_not_overwrite_existing_id() {
1374        let store = test_store().await;
1375        let user = store.create_user(None, None, Role::User).await.unwrap();
1376
1377        // Link with both ID and username
1378        store
1379            .link_telegram(&user.id, Some(100), Some("alice"))
1380            .await
1381            .unwrap();
1382
1383        // Backfill should be a no-op (telegram_id IS NOT NULL)
1384        store.backfill_telegram_id("alice", 999).await.unwrap();
1385
1386        // Original ID should still work
1387        let authed = store
1388            .authenticate_telegram(100, None)
1389            .await
1390            .unwrap()
1391            .unwrap();
1392        assert_eq!(authed.id, user.id);
1393
1394        // The "new" ID should NOT work (backfill only applies to NULL telegram_id)
1395        let result = store.authenticate_telegram(999, None).await.unwrap();
1396        assert!(result.is_none());
1397    }
1398
1399    #[test]
1400    fn role_display() {
1401        assert_eq!(Role::Admin.to_string(), "admin");
1402        assert_eq!(Role::User.to_string(), "user");
1403    }
1404
1405    #[test]
1406    fn role_from_str() {
1407        assert_eq!(Role::from_str("admin"), Some(Role::Admin));
1408        assert_eq!(Role::from_str("user"), Some(Role::User));
1409        assert_eq!(Role::from_str("unknown"), None);
1410    }
1411
1412    #[test]
1413    fn role_serde_roundtrip() {
1414        let json = serde_json::to_string(&Role::Admin).unwrap();
1415        assert_eq!(json, "\"admin\"");
1416        let parsed: Role = serde_json::from_str(&json).unwrap();
1417        assert_eq!(parsed, Role::Admin);
1418    }
1419
1420    #[test]
1421    fn user_serializes_correctly() {
1422        let user = User {
1423            id: "test-id".into(),
1424            email: Some("test@example.com".into()),
1425            display_name: Some("Test".into()),
1426            role: Role::User,
1427            is_active: true,
1428            filesystem_enabled: false,
1429            created_at: Utc::now(),
1430            updated_at: Utc::now(),
1431        };
1432        let json = serde_json::to_string(&user).unwrap();
1433        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1434        assert_eq!(parsed["id"], "test-id");
1435        assert_eq!(parsed["role"], "user");
1436        assert_eq!(parsed["is_active"], true);
1437        assert_eq!(parsed["filesystem_enabled"], false);
1438    }
1439
1440    // ── filesystem_enabled tests ────────────────────────────────────────
1441
1442    #[tokio::test]
1443    async fn create_user_defaults_filesystem_disabled() {
1444        let store = test_store().await;
1445        let user = store
1446            .create_user(None, Some("Test"), Role::User)
1447            .await
1448            .unwrap();
1449        assert!(!user.filesystem_enabled);
1450
1451        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
1452        assert!(!fetched.filesystem_enabled);
1453    }
1454
1455    #[tokio::test]
1456    async fn update_user_enables_filesystem() {
1457        let store = test_store().await;
1458        let user = store
1459            .create_user(None, Some("Test"), Role::User)
1460            .await
1461            .unwrap();
1462        assert!(!user.filesystem_enabled);
1463
1464        store
1465            .update_user(&user.id, None, None, None, Some(true))
1466            .await
1467            .unwrap();
1468        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
1469        assert!(fetched.filesystem_enabled);
1470    }
1471
1472    #[tokio::test]
1473    async fn update_user_disables_filesystem() {
1474        let store = test_store().await;
1475        let user = store
1476            .create_user(None, Some("Test"), Role::User)
1477            .await
1478            .unwrap();
1479        store
1480            .update_user(&user.id, None, None, None, Some(true))
1481            .await
1482            .unwrap();
1483
1484        store
1485            .update_user(&user.id, None, None, None, Some(false))
1486            .await
1487            .unwrap();
1488        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
1489        assert!(!fetched.filesystem_enabled);
1490    }
1491
1492    #[tokio::test]
1493    async fn update_user_none_preserves_filesystem() {
1494        let store = test_store().await;
1495        let user = store
1496            .create_user(None, Some("Test"), Role::User)
1497            .await
1498            .unwrap();
1499        store
1500            .update_user(&user.id, None, None, None, Some(true))
1501            .await
1502            .unwrap();
1503
1504        // Update name only — filesystem should remain enabled
1505        store
1506            .update_user(&user.id, None, Some("NewName"), None, None)
1507            .await
1508            .unwrap();
1509        let fetched = store.get_user(&user.id).await.unwrap().unwrap();
1510        assert!(fetched.filesystem_enabled);
1511        assert_eq!(fetched.display_name.as_deref(), Some("NewName"));
1512    }
1513
1514    #[tokio::test]
1515    async fn list_users_includes_filesystem_field() {
1516        let store = test_store().await;
1517        let u1 = store
1518            .create_user(None, Some("A"), Role::User)
1519            .await
1520            .unwrap();
1521        store
1522            .create_user(None, Some("B"), Role::User)
1523            .await
1524            .unwrap();
1525        store
1526            .update_user(&u1.id, None, None, None, Some(true))
1527            .await
1528            .unwrap();
1529
1530        let users = store.list_users().await.unwrap();
1531        assert_eq!(users.len(), 2);
1532        let a = users
1533            .iter()
1534            .find(|u| u.display_name.as_deref() == Some("A"))
1535            .unwrap();
1536        let b = users
1537            .iter()
1538            .find(|u| u.display_name.as_deref() == Some("B"))
1539            .unwrap();
1540        assert!(a.filesystem_enabled);
1541        assert!(!b.filesystem_enabled);
1542    }
1543
1544    #[tokio::test]
1545    async fn bootstrap_admin_has_filesystem_enabled() {
1546        let store = test_store().await;
1547        let result = store.bootstrap_admin(None).await.unwrap();
1548        let (admin, _key) = result.unwrap();
1549        assert!(
1550            admin.filesystem_enabled,
1551            "Bootstrap admin should have filesystem enabled"
1552        );
1553
1554        // Verify it persists
1555        let fetched = store.get_user(&admin.id).await.unwrap().unwrap();
1556        assert!(fetched.filesystem_enabled);
1557    }
1558
1559    #[tokio::test]
1560    async fn api_key_auth_returns_filesystem_field() {
1561        let store = test_store().await;
1562        let user = store.create_user(None, None, Role::User).await.unwrap();
1563        store
1564            .update_user(&user.id, None, None, None, Some(true))
1565            .await
1566            .unwrap();
1567        let key = store.create_api_key(&user.id, None).await.unwrap();
1568
1569        let authed = store.authenticate_api_key(&key.key).await.unwrap().unwrap();
1570        assert!(authed.filesystem_enabled);
1571    }
1572
1573    #[tokio::test]
1574    async fn telegram_auth_returns_filesystem_field() {
1575        let store = test_store().await;
1576        let user = store.create_user(None, None, Role::User).await.unwrap();
1577        store
1578            .update_user(&user.id, None, None, None, Some(true))
1579            .await
1580            .unwrap();
1581        store
1582            .link_telegram(&user.id, Some(12345), None)
1583            .await
1584            .unwrap();
1585
1586        let authed = store
1587            .authenticate_telegram(12345, None)
1588            .await
1589            .unwrap()
1590            .unwrap();
1591        assert!(authed.filesystem_enabled);
1592    }
1593
1594    #[test]
1595    fn user_serialization_includes_filesystem_enabled() {
1596        let user = User {
1597            id: "u1".into(),
1598            email: None,
1599            display_name: None,
1600            role: Role::Admin,
1601            is_active: true,
1602            filesystem_enabled: true,
1603            created_at: Utc::now(),
1604            updated_at: Utc::now(),
1605        };
1606        let json = serde_json::to_string(&user).unwrap();
1607        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1608        assert_eq!(parsed["filesystem_enabled"], true);
1609    }
1610}