1use 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#[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 pub is_demo: bool,
25 pub demo_label: Option<String>,
26}
27
28impl Identity {
29 pub fn is_admin(&self) -> bool {
32 self.is_active && self.role.includes(Role::Administrator)
33 }
34
35 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
73pub async fn migrate_user_schema(db: &Db) -> Result<()> {
86 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
88 .execute(db.pool())
89 .await?;
90
91 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 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 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 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
182async 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
218pub 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
345pub async fn would_orphan_developers(
361 db: &Db,
362 user_id: i64,
363 new_role: Option<Role>,
364) -> Result<bool> {
365 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 if active_dev_count == 0 {
380 return Ok(false);
381 }
382 if active_dev_count > 1 {
384 return Ok(false);
385 }
386
387 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
397pub 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 #[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 let first_id = create_user(&db, &email, "secret-pw-123", Role::User)
455 .await
456 .unwrap();
457
458 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 let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
485 .bind(first_id)
486 .execute(db.pool())
487 .await;
488 }
489
490 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 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 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 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 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 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 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 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 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 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 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 let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
735 .bind(real_id)
736 .execute(db.pool())
737 .await;
738 }
739
740 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 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 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 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 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 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 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 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 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}