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