Skip to main content

rustio_core/auth/
users.rs

1//! User records, password hashing, and the login flow.
2
3use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
4use argon2::Argon2;
5use chrono::{DateTime, Utc};
6use sqlx::Row as SqlxRow;
7
8use crate::error::{Error, Result};
9use crate::orm::{Db, Row};
10
11use super::role::Role;
12use super::sessions::create_session;
13
14/// The identity attached to a request by the auth middleware. Kept
15/// cheap to clone because we pass it into handler bodies.
16#[derive(Debug, Clone)]
17pub struct Identity {
18    pub user_id: i64,
19    pub email: String,
20    pub role: Role,
21    pub is_active: bool,
22    /// Phase 7a/0.5: whether this user was seeded by the demo
23    /// bootstrap (`RUSTIO_DEMO_MODE=1`). Drives the red banner.
24    pub is_demo: bool,
25    pub demo_label: Option<String>,
26}
27
28impl Identity {
29    /// Administrator-or-higher (Administrator, Developer). Phase 6a/6b
30    /// callers used this to gate the user/group management pages.
31    pub fn is_admin(&self) -> bool {
32        self.is_active && self.role.includes(Role::Administrator)
33    }
34
35    /// Anyone allowed into the admin panel (Staff and above).
36    pub fn can_access_admin(&self) -> bool {
37        self.is_active && self.role.can_access_panel()
38    }
39}
40
41pub struct StoredUser {
42    pub id: i64,
43    pub email: String,
44    pub password_hash: String,
45    pub role: Role,
46    pub is_active: bool,
47    pub is_demo: bool,
48    pub demo_label: Option<String>,
49}
50
51/// Read-only view of a user, used by the built-in admin profile page
52/// and passed into project-registered profile extensions. Excludes
53/// `password_hash` deliberately — extensions must never see credential
54/// material. Construct via [`load_user_profile`].
55#[derive(Debug, Clone)]
56pub struct UserProfile {
57    pub id: i64,
58    pub email: String,
59    pub role: Role,
60    pub is_active: bool,
61    pub created_at: DateTime<Utc>,
62    pub full_name: Option<String>,
63    pub locale: Option<String>,
64    pub timezone: Option<String>,
65    pub is_demo: bool,
66    pub demo_label: Option<String>,
67}
68
69pub async fn init_user_tables(db: &Db) -> Result<()> {
70    sqlx::query(
71        "CREATE TABLE IF NOT EXISTS rustio_users (
72            id            BIGSERIAL PRIMARY KEY,
73            email         TEXT NOT NULL UNIQUE,
74            password_hash TEXT NOT NULL,
75            role          TEXT NOT NULL DEFAULT 'user',
76            is_active     BOOLEAN NOT NULL DEFAULT TRUE,
77            created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
78            updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
79        )",
80    )
81    .execute(db.pool())
82    .await?;
83
84    sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
85        .execute(db.pool())
86        .await?;
87
88    Ok(())
89}
90
91/// Idempotent schema upgrade for the 5-tier role hierarchy + demo flag.
92///
93/// Phase 7a/0.5/a — runs after `init_user_tables` on every boot. Safe to
94/// call repeatedly; safe to run on a fresh DB and on a Phase 6b DB.
95///
96/// Order is load-bearing:
97/// 1. Rename existing `'admin'` rows to `'administrator'` BEFORE the CHECK
98///    constraint exists, otherwise the constraint would reject the row.
99/// 2. Add the two demo columns idempotently.
100/// 3. Add the CHECK constraint conditionally (PG has no `IF NOT EXISTS`
101///    for CHECK constraints, so we guard via `pg_constraint`).
102/// 4. Add the indexes (`CREATE INDEX IF NOT EXISTS` is native).
103pub async fn migrate_user_schema(db: &Db) -> Result<()> {
104    // 1. Rename 'admin' → 'administrator' on existing rows.
105    sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
106        .execute(db.pool())
107        .await?;
108
109    // 2. Add demo columns.
110    sqlx::query(
111        "ALTER TABLE rustio_users \
112         ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
113    )
114    .execute(db.pool())
115    .await?;
116    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
117        .execute(db.pool())
118        .await?;
119
120    // 3. CHECK constraint — guarded by pg_constraint lookup. The DO block
121    //    runs as one statement; sqlx happily executes PL/pgSQL strings.
122    sqlx::query(
123        "DO $$
124         BEGIN
125            IF NOT EXISTS (
126                SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
127            ) THEN
128                ALTER TABLE rustio_users
129                ADD CONSTRAINT rustio_users_role_check
130                CHECK (role IN ('user','staff','supervisor','administrator','developer'));
131            END IF;
132         END $$",
133    )
134    .execute(db.pool())
135    .await?;
136
137    // 4. Indexes.
138    sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
139        .execute(db.pool())
140        .await?;
141    sqlx::query(
142        "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
143         ON rustio_users(is_demo) WHERE is_demo = TRUE",
144    )
145    .execute(db.pool())
146    .await?;
147
148    // 5. Phase 10/a — profile-display columns. All nullable, no defaults,
149    //    no backfill. Read by `load_user_profile` and the built-in user
150    //    show page; never required by the auth path itself.
151    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
152        .execute(db.pool())
153        .await?;
154    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
155        .execute(db.pool())
156        .await?;
157    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
158        .execute(db.pool())
159        .await?;
160
161    Ok(())
162}
163
164pub fn hash_password(plain: &str) -> Result<String> {
165    let salt = SaltString::generate(&mut OsRng);
166    Argon2::default()
167        .hash_password(plain.as_bytes(), &salt)
168        .map(|h| h.to_string())
169        .map_err(|e| Error::Internal(format!("password hashing: {e}")))
170}
171
172pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
173    match PasswordHash::new(stored_hash) {
174        Ok(parsed) => Argon2::default()
175            .verify_password(plain.as_bytes(), &parsed)
176            .is_ok(),
177        Err(_) => false,
178    }
179}
180
181pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
182    let hash = hash_password(password)?;
183    let row = sqlx::query(
184        "INSERT INTO rustio_users (email, password_hash, role)
185         VALUES ($1, $2, $3)
186         RETURNING id",
187    )
188    .bind(email)
189    .bind(&hash)
190    .bind(role.as_str())
191    .fetch_one(db.pool())
192    .await
193    .map_err(|e| {
194        // Phase 7a/0.5/sec4: keep Postgres internals out of the
195        // client response. The full error stays in the operator's
196        // log; the user sees a clean, generic message — except the
197        // unique-email collision, which is worth surfacing because
198        // it's actionable.
199        log::warn!("create_user failed for {email}: {e}");
200        let detail = e.to_string();
201        if detail.contains("rustio_users_email_key") {
202            Error::BadRequest("An account with this email already exists.".into())
203        } else {
204            Error::BadRequest("Could not create user. Please check your input.".into())
205        }
206    })?;
207    let id: i64 = row
208        .try_get("id")
209        .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
210    Ok(id)
211}
212
213/// Phase 7a/0.5/d — INSERT-with-conflict-skip variant of `create_user`
214/// for the demo bootstrap flow. Sets `is_demo = TRUE` and writes an
215/// optional human-readable `demo_label`. Returns `Some(id)` on insert,
216/// `None` if the email is already taken (a real user holds it). The
217/// public `create_user` API is intentionally untouched.
218async fn create_demo_user(
219    db: &Db,
220    email: &str,
221    password: &str,
222    role: Role,
223    demo_label: Option<&str>,
224) -> Result<Option<i64>> {
225    let hash = hash_password(password)?;
226    let row = sqlx::query(
227        "INSERT INTO rustio_users (email, password_hash, role, is_demo, demo_label)
228         VALUES ($1, $2, $3, TRUE, $4)
229         ON CONFLICT (email) DO NOTHING
230         RETURNING id",
231    )
232    .bind(email)
233    .bind(&hash)
234    .bind(role.as_str())
235    .bind(demo_label)
236    .fetch_optional(db.pool())
237    .await?;
238    match row {
239        Some(r) => {
240            let id: i64 = r
241                .try_get("id")
242                .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
243            Ok(Some(id))
244        }
245        None => Ok(None),
246    }
247}
248
249/// Phase 7a/0.5/d — gated by `RUSTIO_DEMO_MODE=1`. Inserts the five
250/// demo users keyed off `branding.domain` (e.g. `staff@rustio.local`)
251/// and attaches each to the matching default groups (which must
252/// already exist; call `bootstrap_default_groups` + `lazy_attach_*`
253/// first). Idempotent via the demo-count gate: re-running on a DB
254/// that already has demo users is a no-op. Real users coexist —
255/// the gate counts only `is_demo = TRUE` rows.
256pub async fn bootstrap_demo_users(
257    db: &Db,
258    branding: &crate::admin::SiteBranding,
259) -> Result<()> {
260    if std::env::var("RUSTIO_DEMO_MODE").as_deref() != Ok("1") {
261        return Ok(());
262    }
263    let demo_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE")
264        .fetch_one(db.pool())
265        .await?;
266    if demo_count > 0 {
267        return Ok(());
268    }
269
270    type DemoSpec = (&'static str, Role, &'static [&'static str]);
271    let demo_specs: [DemoSpec; 5] = [
272        ("user", Role::User, &[]),
273        ("staff", Role::Staff, &["Auditors"]),
274        ("supervisor", Role::Supervisor, &["System Operators"]),
275        (
276            "administrator",
277            Role::Administrator,
278            &[
279                "Auditors",
280                "Content Editors",
281                "HR Managers",
282                "Finance",
283                "Project Coordinators",
284                "System Operators",
285            ],
286        ),
287        (
288            "developer",
289            Role::Developer,
290            &[
291                "Auditors",
292                "Content Editors",
293                "HR Managers",
294                "Finance",
295                "Project Coordinators",
296                "System Operators",
297            ],
298        ),
299    ];
300
301    let mut created = 0usize;
302    for (slug, role, group_names) in demo_specs {
303        let email = format!("{slug}@{}", branding.domain);
304        let label = format!("Demo {}", role.label());
305        match create_demo_user(db, &email, slug, role, Some(&label)).await? {
306            Some(user_id) => {
307                created += 1;
308                for group_name in group_names {
309                    if let Some(group_id) =
310                        crate::auth::permissions::find_group_id_by_name(db, group_name).await?
311                    {
312                        crate::auth::add_user_to_group(db, user_id, group_id).await?;
313                    }
314                }
315            }
316            None => {
317                log::warn!("RUSTIO_DEMO_MODE: skipping demo user {email} — email already taken");
318            }
319        }
320    }
321    log::info!("RUSTIO_DEMO_MODE: created {created} demo users (passwords match role slugs)");
322    Ok(())
323}
324
325pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
326    let row = sqlx::query(
327        "SELECT id, email, password_hash, role, is_active, is_demo, demo_label
328           FROM rustio_users
329          WHERE email = $1",
330    )
331    .bind(email)
332    .fetch_optional(db.pool())
333    .await?;
334    match row {
335        Some(r) => {
336            let r = Row::from_pg(&r);
337            Ok(Some(StoredUser {
338                id: r.get_i64("id")?,
339                email: r.get_string("email")?,
340                password_hash: r.get_string("password_hash")?,
341                role: Role::parse(&r.get_string("role")?)?,
342                is_active: r.get_bool("is_active")?,
343                is_demo: r.get_bool("is_demo")?,
344                demo_label: r.get_optional_string("demo_label")?,
345            }))
346        }
347        None => Ok(None),
348    }
349}
350
351/// Load a user by id for display purposes. Returns `Ok(None)` for a
352/// missing id (callers map to 404). Returns `Err` only on a real DB
353/// failure or a corrupted role string.
354///
355/// Phase 10/a — companion to [`UserProfile`]. Reads the columns added
356/// by `migrate_user_schema` (full_name, locale, timezone) plus the
357/// existing demo flags. Never reads `password_hash`.
358pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
359    let row = sqlx::query(
360        "SELECT id, email, role, is_active, created_at,
361                full_name, locale, timezone, is_demo, demo_label
362           FROM rustio_users
363          WHERE id = $1",
364    )
365    .bind(user_id)
366    .fetch_optional(db.pool())
367    .await?;
368    match row {
369        Some(r) => {
370            let r = Row::from_pg(&r);
371            Ok(Some(UserProfile {
372                id: r.get_i64("id")?,
373                email: r.get_string("email")?,
374                role: Role::parse(&r.get_string("role")?)?,
375                is_active: r.get_bool("is_active")?,
376                created_at: r.get_datetime("created_at")?,
377                full_name: r.get_optional_string("full_name")?,
378                locale: r.get_optional_string("locale")?,
379                timezone: r.get_optional_string("timezone")?,
380                is_demo: r.get_bool("is_demo")?,
381                demo_label: r.get_optional_string("demo_label")?,
382            }))
383        }
384        None => Ok(None),
385    }
386}
387
388pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
389    let hash = hash_password(new_password)?;
390    sqlx::query(
391        "UPDATE rustio_users SET password_hash = $1, updated_at = $2 WHERE id = $3",
392    )
393    .bind(&hash)
394    .bind(Utc::now())
395    .bind(user_id)
396    .execute(db.pool())
397    .await?;
398    Ok(())
399}
400
401pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
402    sqlx::query(
403        "UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3",
404    )
405    .bind(role.as_str())
406    .bind(Utc::now())
407    .bind(user_id)
408    .execute(db.pool())
409    .await?;
410    Ok(())
411}
412
413/// Phase 7a/0.5/f — would the proposed change leave the system with
414/// zero active Developers?
415///
416/// `new_role`:
417/// - `None` → user is being deleted entirely.
418/// - `Some(role)` → user's role is being changed to `role`.
419///
420/// Returns `true` only when:
421/// - exactly one active Developer exists, AND
422/// - the target user IS that Developer, AND
423/// - the action would remove their Developer status (deletion or
424///   demotion to anything other than Developer).
425///
426/// Used as a server-side guard in `do_user_edit` and `do_user_delete`,
427/// and as a CLI warning before destructive role changes.
428pub async fn would_orphan_developers(
429    db: &Db,
430    user_id: i64,
431    new_role: Option<Role>,
432) -> Result<bool> {
433    // Cheap-out: if the change KEEPS the user as a Developer, no orphan risk.
434    if matches!(new_role, Some(Role::Developer)) {
435        return Ok(false);
436    }
437
438    let active_dev_count: i64 = sqlx::query_scalar(
439        "SELECT COUNT(*) FROM rustio_users \
440         WHERE role = 'developer' AND is_active = TRUE",
441    )
442    .fetch_one(db.pool())
443    .await?;
444
445    // No-developers → not orphaning anyone (a fresh DB pre-bootstrap
446    // is allowed, by design).
447    if active_dev_count == 0 {
448        return Ok(false);
449    }
450    // 2+ developers → demoting/deleting one always leaves ≥1 left.
451    if active_dev_count > 1 {
452        return Ok(false);
453    }
454
455    // Exactly one. Is it `user_id`?
456    let target_role: Option<String> = sqlx::query_scalar(
457        "SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE",
458    )
459    .bind(user_id)
460    .fetch_optional(db.pool())
461    .await?;
462    Ok(target_role.as_deref() == Some("developer"))
463}
464
465/// Verify credentials and create a session. Returns the session token
466/// to set in the cookie. A deliberately vague error on failure — we
467/// don't want to leak whether the email was valid.
468pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
469    let user = find_user_by_email(db, email)
470        .await?
471        .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
472    if !user.is_active {
473        return Err(Error::Forbidden("account disabled".into()));
474    }
475    if !verify_password(password, &user.password_hash) {
476        return Err(Error::Unauthorized("invalid email or password".into()));
477    }
478    create_session(db, user.id).await
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn user_profile_derives_debug_and_clone() {
487        // Phase 10/a — UserProfile must be Debug + Clone so handlers
488        // and template-context builders can format it and pass it by
489        // value into the project extension closure without ceremony.
490        fn assert_traits<T: std::fmt::Debug + Clone>() {}
491        assert_traits::<UserProfile>();
492    }
493
494    #[test]
495    fn password_round_trip() {
496        let h = hash_password("secret").unwrap();
497        assert!(verify_password("secret", &h));
498        assert!(!verify_password("wrong", &h));
499    }
500
501    // `Role` parsing + ladder semantics moved to `auth/role.rs`
502    // (25-case `includes` matrix + parse round-trip).
503
504    /// Phase 7a/0.5/sec4 regression: duplicate-email creation must
505    /// surface a clean, actionable message — never the raw Postgres
506    /// constraint name, never an SQLSTATE code.
507    #[tokio::test]
508    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
509    async fn duplicate_email_is_clean_error_message() {
510        let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
511            .unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
512        let opts = crate::orm::DbOptions {
513            max_connections: 2,
514            ..crate::orm::DbOptions::default()
515        };
516        let db = crate::orm::Db::connect_with(&url, opts).await.unwrap();
517        crate::auth::init_user_tables(&db).await.unwrap();
518        crate::auth::migrate_user_schema(&db).await.unwrap();
519
520        let tag = format!(
521            "dup_{}_{}",
522            std::process::id(),
523            std::time::SystemTime::now()
524                .duration_since(std::time::UNIX_EPOCH)
525                .unwrap()
526                .as_nanos()
527        );
528        let email = format!("{tag}@example.test");
529
530        // First insert succeeds.
531        let first_id = create_user(&db, &email, "secret-pw-123", Role::User)
532            .await
533            .unwrap();
534
535        // Second insert fails — assert the response message is clean
536        // and contains no Postgres-internal detail.
537        let err = create_user(&db, &email, "secret-pw-123", Role::User)
538            .await
539            .unwrap_err();
540        let msg = err.to_string();
541        assert!(
542            msg.contains("already exists"),
543            "expected actionable duplicate-email message, got: {msg}"
544        );
545        for leaked in [
546            "rustio_users_email_key",
547            "duplicate key value",
548            "constraint",
549            "SQLSTATE",
550            "23505",
551            "Postgres",
552            "pg::",
553        ] {
554            assert!(
555                !msg.contains(leaked),
556                "client message must NOT contain {leaked:?}, got: {msg}"
557            );
558        }
559
560        // Cleanup.
561        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
562            .bind(first_id)
563            .execute(db.pool())
564            .await;
565    }
566
567    // ------------------------------------------------------------------
568    // Phase 7a/0.5/d — bootstrap_demo_users
569    // ------------------------------------------------------------------
570
571    use crate::auth::TEST_ENV_LOCK as ENV_LOCK;
572
573    async fn pg_db() -> crate::orm::Db {
574        let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
575            .unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
576        let opts = crate::orm::DbOptions {
577            max_connections: 2,
578            ..crate::orm::DbOptions::default()
579        };
580        crate::orm::Db::connect_with(&url, opts).await.unwrap()
581    }
582
583    /// Wipe every demo user + every default group on the test DB so
584    /// each test starts from a clean slate. Cascades through the M2M.
585    async fn reset_demo_state(db: &crate::orm::Db) {
586        let _ = sqlx::query("DELETE FROM rustio_users WHERE is_demo = TRUE")
587            .execute(db.pool())
588            .await;
589        // Match the 6 default group names from permissions.rs.
590        for name in [
591            "Auditors",
592            "Content Editors",
593            "HR Managers",
594            "Finance",
595            "Project Coordinators",
596            "System Operators",
597        ] {
598            let _ = sqlx::query("DELETE FROM rustio_groups WHERE name = $1")
599                .bind(name)
600                .execute(db.pool())
601                .await;
602        }
603    }
604
605    fn test_branding() -> crate::admin::SiteBranding {
606        crate::admin::SiteBranding {
607            domain: "rustio.local".into(),
608            ..crate::admin::SiteBranding::default()
609        }
610    }
611
612    #[tokio::test]
613    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
614    async fn bootstrap_creates_five_demo_users() {
615        let _env = ENV_LOCK.lock().await;
616        let db = pg_db().await;
617        crate::auth::init_user_tables(&db).await.unwrap();
618        crate::auth::migrate_user_schema(&db).await.unwrap();
619        crate::auth::init_permission_tables(&db).await.unwrap();
620        reset_demo_state(&db).await;
621
622        std::env::set_var("RUSTIO_DEMO_MODE", "1");
623        crate::auth::bootstrap_default_groups(&db).await.unwrap();
624        bootstrap_demo_users(&db, &test_branding()).await.unwrap();
625
626        let count: i64 = sqlx::query_scalar(
627            "SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE \
628             AND email LIKE '%@rustio.local'",
629        )
630        .fetch_one(db.pool())
631        .await
632        .unwrap();
633        assert_eq!(count, 5, "expected 5 demo users, got {count}");
634
635        std::env::remove_var("RUSTIO_DEMO_MODE");
636        reset_demo_state(&db).await;
637    }
638
639    #[tokio::test]
640    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
641    async fn bootstrap_skips_when_demo_users_already_exist() {
642        let _env = ENV_LOCK.lock().await;
643        let db = pg_db().await;
644        crate::auth::init_user_tables(&db).await.unwrap();
645        crate::auth::migrate_user_schema(&db).await.unwrap();
646        crate::auth::init_permission_tables(&db).await.unwrap();
647        reset_demo_state(&db).await;
648
649        std::env::set_var("RUSTIO_DEMO_MODE", "1");
650        crate::auth::bootstrap_default_groups(&db).await.unwrap();
651        bootstrap_demo_users(&db, &test_branding()).await.unwrap();
652        let first: i64 = sqlx::query_scalar(
653            "SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE",
654        )
655        .fetch_one(db.pool())
656        .await
657        .unwrap();
658        assert_eq!(first, 5);
659
660        // Re-run — gate must short-circuit and add nothing.
661        bootstrap_demo_users(&db, &test_branding()).await.unwrap();
662        let second: i64 = sqlx::query_scalar(
663            "SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE",
664        )
665        .fetch_one(db.pool())
666        .await
667        .unwrap();
668        assert_eq!(first, second, "second bootstrap must NOT add rows");
669
670        std::env::remove_var("RUSTIO_DEMO_MODE");
671        reset_demo_state(&db).await;
672    }
673
674    #[tokio::test]
675    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
676    async fn bootstrap_assigns_groups_correctly() {
677        let _env = ENV_LOCK.lock().await;
678        let db = pg_db().await;
679        crate::auth::init_user_tables(&db).await.unwrap();
680        crate::auth::migrate_user_schema(&db).await.unwrap();
681        crate::auth::init_permission_tables(&db).await.unwrap();
682        reset_demo_state(&db).await;
683
684        std::env::set_var("RUSTIO_DEMO_MODE", "1");
685        crate::auth::bootstrap_default_groups(&db).await.unwrap();
686        bootstrap_demo_users(&db, &test_branding()).await.unwrap();
687
688        // staff → 1 group (Auditors)
689        let staff_count: i64 = sqlx::query_scalar(
690            "SELECT COUNT(*) FROM rustio_user_groups ug \
691             JOIN rustio_users u ON u.id = ug.user_id \
692             WHERE u.email = $1",
693        )
694        .bind("staff@rustio.local")
695        .fetch_one(db.pool())
696        .await
697        .unwrap();
698        assert_eq!(staff_count, 1, "staff should belong to 1 group");
699
700        // administrator → 6 groups (every default)
701        let admin_count: i64 = sqlx::query_scalar(
702            "SELECT COUNT(*) FROM rustio_user_groups ug \
703             JOIN rustio_users u ON u.id = ug.user_id \
704             WHERE u.email = $1",
705        )
706        .bind("administrator@rustio.local")
707        .fetch_one(db.pool())
708        .await
709        .unwrap();
710        assert_eq!(admin_count, 6, "administrator should belong to all 6");
711
712        // user → 0 groups
713        let user_count: i64 = sqlx::query_scalar(
714            "SELECT COUNT(*) FROM rustio_user_groups ug \
715             JOIN rustio_users u ON u.id = ug.user_id \
716             WHERE u.email = $1",
717        )
718        .bind("user@rustio.local")
719        .fetch_one(db.pool())
720        .await
721        .unwrap();
722        assert_eq!(user_count, 0, "user has no group memberships");
723
724        std::env::remove_var("RUSTIO_DEMO_MODE");
725        reset_demo_state(&db).await;
726    }
727
728    #[tokio::test]
729    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
730    async fn demo_user_emails_use_branding_domain() {
731        let _env = ENV_LOCK.lock().await;
732        let db = pg_db().await;
733        crate::auth::init_user_tables(&db).await.unwrap();
734        crate::auth::migrate_user_schema(&db).await.unwrap();
735        crate::auth::init_permission_tables(&db).await.unwrap();
736        reset_demo_state(&db).await;
737
738        std::env::set_var("RUSTIO_DEMO_MODE", "1");
739        crate::auth::bootstrap_default_groups(&db).await.unwrap();
740
741        // Use a non-default domain to prove branding flows through.
742        let branding = crate::admin::SiteBranding {
743            domain: "tolkhuset.test".into(),
744            ..crate::admin::SiteBranding::default()
745        };
746        bootstrap_demo_users(&db, &branding).await.unwrap();
747
748        let emails: Vec<String> = sqlx::query_scalar(
749            "SELECT email FROM rustio_users WHERE is_demo = TRUE ORDER BY email",
750        )
751        .fetch_all(db.pool())
752        .await
753        .unwrap();
754        assert_eq!(emails.len(), 5);
755        for e in &emails {
756            assert!(
757                e.ends_with("@tolkhuset.test"),
758                "demo email should use branding domain, got: {e}"
759            );
760        }
761
762        std::env::remove_var("RUSTIO_DEMO_MODE");
763        reset_demo_state(&db).await;
764    }
765
766    #[tokio::test]
767    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
768    async fn real_user_unaffected_by_demo_bootstrap() {
769        let _env = ENV_LOCK.lock().await;
770        let db = pg_db().await;
771        crate::auth::init_user_tables(&db).await.unwrap();
772        crate::auth::migrate_user_schema(&db).await.unwrap();
773        crate::auth::init_permission_tables(&db).await.unwrap();
774        reset_demo_state(&db).await;
775
776        // Seed a real user. Must NOT be flagged is_demo afterward.
777        let real_email = format!(
778            "real_{}_{}@example.test",
779            std::process::id(),
780            std::time::SystemTime::now()
781                .duration_since(std::time::UNIX_EPOCH)
782                .unwrap()
783                .as_nanos()
784        );
785        let real_id = create_user(&db, &real_email, "secret-pw-123", Role::User)
786            .await
787            .unwrap();
788
789        std::env::set_var("RUSTIO_DEMO_MODE", "1");
790        crate::auth::bootstrap_default_groups(&db).await.unwrap();
791        bootstrap_demo_users(&db, &test_branding()).await.unwrap();
792
793        // The real user's row is unchanged.
794        let row = find_user_by_email(&db, &real_email).await.unwrap().unwrap();
795        assert!(!row.is_demo, "real user must NOT be flagged is_demo");
796        assert_eq!(row.demo_label, None, "real user must NOT have a demo_label");
797        assert_eq!(row.role, Role::User, "real user's role must be unchanged");
798
799        // Demo users coexist with the real user.
800        let demo_count: i64 = sqlx::query_scalar(
801            "SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE",
802        )
803        .fetch_one(db.pool())
804        .await
805        .unwrap();
806        assert_eq!(demo_count, 5);
807
808        std::env::remove_var("RUSTIO_DEMO_MODE");
809        reset_demo_state(&db).await;
810        // Cleanup the real user.
811        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
812            .bind(real_id)
813            .execute(db.pool())
814            .await;
815    }
816
817    // ------------------------------------------------------------------
818    // Phase 7a/0.5/f — would_orphan_developers
819    //
820    // The helper is the single source of truth for "is this change
821    // about to leave the system without a developer?". The UI guard
822    // (`do_user_edit`, `do_user_delete`) and the CLI confirmation
823    // (`user role set`) all delegate to it, so the contract MUST hold:
824    // - sole active dev demoted/deleted → true
825    // - two active devs, one demoted    → false
826    // - inactive devs don't count toward the active pool
827    // - non-dev targets never trigger
828    // - "zero devs" pre-bootstrap is allowed
829    // ------------------------------------------------------------------
830
831    /// Insert a unique-emailed user for orphan-guard tests. Returns
832    /// the new id; caller cleans up via `delete_user(...)` to keep the
833    /// DB tidy (these rows are NOT flagged `is_demo` so
834    /// `reset_demo_state` won't catch them).
835    async fn make_user(db: &crate::orm::Db, role: Role, is_active: bool) -> i64 {
836        let email = format!(
837            "orphan_{}_{}_{}@example.test",
838            std::process::id(),
839            std::time::SystemTime::now()
840                .duration_since(std::time::UNIX_EPOCH)
841                .unwrap()
842                .as_nanos(),
843            // tie-break in case two calls land on the same nanosecond.
844            rand::random::<u32>(),
845        );
846        let id = create_user(db, &email, "secret-pw-123", role).await.unwrap();
847        if !is_active {
848            sqlx::query("UPDATE rustio_users SET is_active = FALSE WHERE id = $1")
849                .bind(id)
850                .execute(db.pool())
851                .await
852                .unwrap();
853        }
854        id
855    }
856
857    async fn delete_user(db: &crate::orm::Db, id: i64) {
858        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
859            .bind(id)
860            .execute(db.pool())
861            .await;
862    }
863
864    /// Snapshot of pre-existing developer ids so we can restore the DB
865    /// to its starting state. Tests run against a shared dev DB and
866    /// must not leak rows or flip seeded users' active flags.
867    async fn snapshot_active_devs(db: &crate::orm::Db) -> Vec<i64> {
868        sqlx::query_scalar(
869            "SELECT id FROM rustio_users \
870             WHERE role = 'developer' AND is_active = TRUE",
871        )
872        .fetch_all(db.pool())
873        .await
874        .unwrap()
875    }
876
877    /// Move every active developer NOT in `keep` to `is_active = FALSE`
878    /// for the duration of a test. We restore them in
879    /// `restore_active_devs`. We deactivate (rather than delete) so
880    /// FK references (sessions, group memberships) survive.
881    async fn isolate_developers(db: &crate::orm::Db, keep: &[i64]) -> Vec<i64> {
882        let snapshot = snapshot_active_devs(db).await;
883        for id in &snapshot {
884            if !keep.contains(id) {
885                sqlx::query("UPDATE rustio_users SET is_active = FALSE WHERE id = $1")
886                    .bind(id)
887                    .execute(db.pool())
888                    .await
889                    .unwrap();
890            }
891        }
892        snapshot
893    }
894
895    async fn restore_active_devs(db: &crate::orm::Db, ids: &[i64]) {
896        for id in ids {
897            let _ = sqlx::query("UPDATE rustio_users SET is_active = TRUE WHERE id = $1")
898                .bind(id)
899                .execute(db.pool())
900                .await;
901        }
902    }
903
904    #[tokio::test]
905    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
906    async fn orphan_when_sole_active_dev_demoted_to_user() {
907        let _env = ENV_LOCK.lock().await;
908        let db = pg_db().await;
909        crate::auth::init_user_tables(&db).await.unwrap();
910        crate::auth::migrate_user_schema(&db).await.unwrap();
911
912        let dev = make_user(&db, Role::Developer, true).await;
913        let restore = isolate_developers(&db, &[dev]).await;
914
915        let orphan = would_orphan_developers(&db, dev, Some(Role::User))
916            .await
917            .unwrap();
918        assert!(orphan, "demoting the sole active developer must orphan");
919
920        restore_active_devs(&db, &restore).await;
921        delete_user(&db, dev).await;
922    }
923
924    #[tokio::test]
925    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
926    async fn no_orphan_when_sole_dev_kept_as_dev() {
927        let _env = ENV_LOCK.lock().await;
928        let db = pg_db().await;
929        crate::auth::init_user_tables(&db).await.unwrap();
930        crate::auth::migrate_user_schema(&db).await.unwrap();
931
932        let dev = make_user(&db, Role::Developer, true).await;
933        let restore = isolate_developers(&db, &[dev]).await;
934
935        // Identity update — the cheap-out path returns false without
936        // even querying the DB.
937        let orphan = would_orphan_developers(&db, dev, Some(Role::Developer))
938            .await
939            .unwrap();
940        assert!(!orphan, "Developer → Developer is a no-op, never orphans");
941
942        restore_active_devs(&db, &restore).await;
943        delete_user(&db, dev).await;
944    }
945
946    #[tokio::test]
947    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
948    async fn no_orphan_when_two_active_devs() {
949        let _env = ENV_LOCK.lock().await;
950        let db = pg_db().await;
951        crate::auth::init_user_tables(&db).await.unwrap();
952        crate::auth::migrate_user_schema(&db).await.unwrap();
953
954        let dev_a = make_user(&db, Role::Developer, true).await;
955        let dev_b = make_user(&db, Role::Developer, true).await;
956        let restore = isolate_developers(&db, &[dev_a, dev_b]).await;
957
958        // Demoting either still leaves the other.
959        let orphan_a = would_orphan_developers(&db, dev_a, Some(Role::User))
960            .await
961            .unwrap();
962        let orphan_b = would_orphan_developers(&db, dev_b, Some(Role::Administrator))
963            .await
964            .unwrap();
965        assert!(!orphan_a, "two devs → demoting A leaves B");
966        assert!(!orphan_b, "two devs → demoting B leaves A");
967
968        restore_active_devs(&db, &restore).await;
969        delete_user(&db, dev_a).await;
970        delete_user(&db, dev_b).await;
971    }
972
973    #[tokio::test]
974    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
975    async fn inactive_devs_do_not_count() {
976        let _env = ENV_LOCK.lock().await;
977        let db = pg_db().await;
978        crate::auth::init_user_tables(&db).await.unwrap();
979        crate::auth::migrate_user_schema(&db).await.unwrap();
980
981        let active_dev = make_user(&db, Role::Developer, true).await;
982        let inactive_dev = make_user(&db, Role::Developer, false).await;
983        let restore = isolate_developers(&db, &[active_dev]).await;
984
985        // Even though there's an "inactive developer" in the table,
986        // they don't count toward the active pool — demoting the only
987        // active one still orphans the system.
988        let orphan = would_orphan_developers(&db, active_dev, Some(Role::User))
989            .await
990            .unwrap();
991        assert!(
992            orphan,
993            "inactive developers must not satisfy the active-dev requirement"
994        );
995
996        restore_active_devs(&db, &restore).await;
997        delete_user(&db, active_dev).await;
998        delete_user(&db, inactive_dev).await;
999    }
1000
1001    #[tokio::test]
1002    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
1003    async fn non_developer_target_never_orphans() {
1004        let _env = ENV_LOCK.lock().await;
1005        let db = pg_db().await;
1006        crate::auth::init_user_tables(&db).await.unwrap();
1007        crate::auth::migrate_user_schema(&db).await.unwrap();
1008
1009        let dev = make_user(&db, Role::Developer, true).await;
1010        let staff = make_user(&db, Role::Staff, true).await;
1011        let restore = isolate_developers(&db, &[dev]).await;
1012
1013        // Demoting / deactivating a non-developer never orphans the
1014        // developer pool, regardless of how many devs exist.
1015        let orphan = would_orphan_developers(&db, staff, Some(Role::User))
1016            .await
1017            .unwrap();
1018        assert!(!orphan, "demoting a non-developer can't orphan developers");
1019
1020        restore_active_devs(&db, &restore).await;
1021        delete_user(&db, dev).await;
1022        delete_user(&db, staff).await;
1023    }
1024
1025    #[tokio::test]
1026    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
1027    async fn zero_developers_is_not_an_orphan_state() {
1028        let _env = ENV_LOCK.lock().await;
1029        let db = pg_db().await;
1030        crate::auth::init_user_tables(&db).await.unwrap();
1031        crate::auth::migrate_user_schema(&db).await.unwrap();
1032
1033        // Park every active dev as inactive — pre-bootstrap fresh-DB
1034        // simulation — and demote a random non-dev. Must NOT orphan.
1035        let restore = isolate_developers(&db, &[]).await;
1036        let staff = make_user(&db, Role::Staff, true).await;
1037
1038        let orphan = would_orphan_developers(&db, staff, Some(Role::User))
1039            .await
1040            .unwrap();
1041        assert!(
1042            !orphan,
1043            "a zero-developer DB is allowed; the guard only kicks in once at least one dev exists"
1044        );
1045
1046        restore_active_devs(&db, &restore).await;
1047        delete_user(&db, staff).await;
1048    }
1049
1050    // ------------------------------------------------------------------
1051    // Phase 10/a — UserProfile + load_user_profile
1052    // ------------------------------------------------------------------
1053
1054    fn unique_email(tag: &str) -> String {
1055        let pid = std::process::id();
1056        let nanos = std::time::SystemTime::now()
1057            .duration_since(std::time::UNIX_EPOCH)
1058            .unwrap()
1059            .as_nanos();
1060        format!("{tag}_{pid}_{nanos}@example.test")
1061    }
1062
1063    /// E.1 (PG) — running `init_tables` twice is a no-op the second time;
1064    /// the new profile columns and session columns must be present after.
1065    #[tokio::test]
1066    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
1067    async fn migration_is_idempotent_and_columns_present() {
1068        let db = pg_db().await;
1069        crate::auth::init_tables(&db).await.unwrap();
1070        // Second run must not error.
1071        crate::auth::init_tables(&db).await.unwrap();
1072
1073        let user_cols: Vec<(String,)> = sqlx::query_as(
1074            "SELECT column_name::text FROM information_schema.columns
1075             WHERE table_name = 'rustio_users'
1076               AND column_name IN ('full_name','locale','timezone')
1077             ORDER BY column_name",
1078        )
1079        .fetch_all(db.pool())
1080        .await
1081        .unwrap();
1082        assert_eq!(user_cols.len(), 3, "expected 3 new user columns, got {user_cols:?}");
1083
1084        let session_cols: Vec<(String,)> = sqlx::query_as(
1085            "SELECT column_name::text FROM information_schema.columns
1086             WHERE table_name = 'rustio_sessions'
1087               AND column_name IN ('ip','user_agent')
1088             ORDER BY column_name",
1089        )
1090        .fetch_all(db.pool())
1091        .await
1092        .unwrap();
1093        assert_eq!(session_cols.len(), 2, "expected 2 new session columns, got {session_cols:?}");
1094    }
1095
1096    /// E.2 (PG) — `load_user_profile` returns a fully-populated `UserProfile`
1097    /// for an existing user; the new optional columns default to None on a
1098    /// freshly-created user.
1099    #[tokio::test]
1100    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
1101    async fn load_user_profile_happy_path() {
1102        let db = pg_db().await;
1103        crate::auth::init_tables(&db).await.unwrap();
1104
1105        let email = unique_email("profile_happy");
1106        let id = create_user(&db, &email, "secret-pw-123", Role::Staff)
1107            .await
1108            .unwrap();
1109
1110        let profile = load_user_profile(&db, id).await.unwrap().expect("user exists");
1111        assert_eq!(profile.id, id);
1112        assert_eq!(profile.email, email);
1113        assert_eq!(profile.role, Role::Staff);
1114        assert!(profile.is_active);
1115        assert!(profile.full_name.is_none());
1116        assert!(profile.locale.is_none());
1117        assert!(profile.timezone.is_none());
1118        assert!(!profile.is_demo);
1119        assert!(profile.demo_label.is_none());
1120
1121        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
1122            .bind(id)
1123            .execute(db.pool())
1124            .await;
1125    }
1126
1127    /// E.3 (PG) — `load_user_profile` for a missing id returns Ok(None),
1128    /// not Err. Callers map None to 404 themselves.
1129    #[tokio::test]
1130    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
1131    async fn load_user_profile_missing_returns_none() {
1132        let db = pg_db().await;
1133        crate::auth::init_tables(&db).await.unwrap();
1134        let result = load_user_profile(&db, 999_999_999).await.unwrap();
1135        assert!(result.is_none(), "missing id must yield Ok(None)");
1136    }
1137
1138    /// E.4 (PG) — existing CRUD path (create → find → set_password) keeps
1139    /// working after the migration. Smoke; not a re-run of the full auth
1140    /// suite.
1141    #[tokio::test]
1142    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
1143    async fn existing_user_crud_unaffected_by_migration() {
1144        let db = pg_db().await;
1145        crate::auth::init_tables(&db).await.unwrap();
1146
1147        let email = unique_email("crud_smoke");
1148        let id = create_user(&db, &email, "secret-pw-123", Role::User)
1149            .await
1150            .unwrap();
1151
1152        let found = find_user_by_email(&db, &email).await.unwrap().expect("found");
1153        assert_eq!(found.id, id);
1154
1155        set_password(&db, id, "new-secret-456").await.unwrap();
1156        let after = find_user_by_email(&db, &email).await.unwrap().expect("still there");
1157        assert!(verify_password("new-secret-456", &after.password_hash));
1158
1159        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
1160            .bind(id)
1161            .execute(db.pool())
1162            .await;
1163    }
1164}