1use 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#[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
51#[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
91pub async fn migrate_user_schema(db: &Db) -> Result<()> {
104 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
106 .execute(db.pool())
107 .await?;
108
109 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 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 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 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 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
213async 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
249pub 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
351pub 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
413pub async fn would_orphan_developers(
429 db: &Db,
430 user_id: i64,
431 new_role: Option<Role>,
432) -> Result<bool> {
433 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 if active_dev_count == 0 {
448 return Ok(false);
449 }
450 if active_dev_count > 1 {
452 return Ok(false);
453 }
454
455 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
465pub 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 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 #[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 let first_id = create_user(&db, &email, "secret-pw-123", Role::User)
532 .await
533 .unwrap();
534
535 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 let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
562 .bind(first_id)
563 .execute(db.pool())
564 .await;
565 }
566
567 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 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 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 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 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 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 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 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 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 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 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 let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
812 .bind(real_id)
813 .execute(db.pool())
814 .await;
815 }
816
817 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 #[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}