use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use chrono::{DateTime, Utc};
use sqlx::Row as SqlxRow;
use crate::error::{Error, Result};
use crate::orm::{Db, Row};
use super::role::Role;
use super::sessions::create_session;
#[derive(Debug, Clone)]
pub struct Identity {
pub user_id: i64,
pub email: String,
pub role: Role,
pub is_active: bool,
pub is_demo: bool,
pub demo_label: Option<String>,
}
impl Identity {
pub fn is_admin(&self) -> bool {
self.is_active && self.role.includes(Role::Administrator)
}
pub fn can_access_admin(&self) -> bool {
self.is_active && self.role.can_access_panel()
}
}
pub struct StoredUser {
pub id: i64,
pub email: String,
pub password_hash: String,
pub role: Role,
pub is_active: bool,
pub is_demo: bool,
pub demo_label: Option<String>,
}
#[derive(Debug, Clone)]
pub struct UserProfile {
pub id: i64,
pub email: String,
pub role: Role,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub full_name: Option<String>,
pub locale: Option<String>,
pub timezone: Option<String>,
pub is_demo: bool,
pub demo_label: Option<String>,
}
pub async fn init_user_tables(db: &Db) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS rustio_users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)",
)
.execute(db.pool())
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
.execute(db.pool())
.await?;
Ok(())
}
pub async fn migrate_user_schema(db: &Db) -> Result<()> {
sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
.execute(db.pool())
.await?;
sqlx::query(
"ALTER TABLE rustio_users \
ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
)
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
.execute(db.pool())
.await?;
sqlx::query(
"DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
) THEN
ALTER TABLE rustio_users
ADD CONSTRAINT rustio_users_role_check
CHECK (role IN ('user','staff','supervisor','administrator','developer'));
END IF;
END $$",
)
.execute(db.pool())
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
.execute(db.pool())
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
ON rustio_users(is_demo) WHERE is_demo = TRUE",
)
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
.execute(db.pool())
.await?;
Ok(())
}
pub fn hash_password(plain: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| Error::Internal(format!("password hashing: {e}")))
}
pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
match PasswordHash::new(stored_hash) {
Ok(parsed) => Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
let hash = hash_password(password)?;
let row = sqlx::query(
"INSERT INTO rustio_users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id",
)
.bind(email)
.bind(&hash)
.bind(role.as_str())
.fetch_one(db.pool())
.await
.map_err(|e| {
log::warn!("create_user failed for {email}: {e}");
let detail = e.to_string();
if detail.contains("rustio_users_email_key") {
Error::BadRequest("An account with this email already exists.".into())
} else {
Error::BadRequest("Could not create user. Please check your input.".into())
}
})?;
let id: i64 = row
.try_get("id")
.map_err(|e| Error::Internal(format!("returning id: {e}")))?;
Ok(id)
}
async fn create_demo_user(
db: &Db,
email: &str,
password: &str,
role: Role,
demo_label: Option<&str>,
) -> Result<Option<i64>> {
let hash = hash_password(password)?;
let row = sqlx::query(
"INSERT INTO rustio_users (email, password_hash, role, is_demo, demo_label)
VALUES ($1, $2, $3, TRUE, $4)
ON CONFLICT (email) DO NOTHING
RETURNING id",
)
.bind(email)
.bind(&hash)
.bind(role.as_str())
.bind(demo_label)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let id: i64 = r
.try_get("id")
.map_err(|e| Error::Internal(format!("returning id: {e}")))?;
Ok(Some(id))
}
None => Ok(None),
}
}
pub async fn bootstrap_demo_users(
db: &Db,
branding: &crate::admin::SiteBranding,
) -> Result<()> {
if std::env::var("RUSTIO_DEMO_MODE").as_deref() != Ok("1") {
return Ok(());
}
let demo_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE")
.fetch_one(db.pool())
.await?;
if demo_count > 0 {
return Ok(());
}
type DemoSpec = (&'static str, Role, &'static [&'static str]);
let demo_specs: [DemoSpec; 5] = [
("user", Role::User, &[]),
("staff", Role::Staff, &["Auditors"]),
("supervisor", Role::Supervisor, &["System Operators"]),
(
"administrator",
Role::Administrator,
&[
"Auditors",
"Content Editors",
"HR Managers",
"Finance",
"Project Coordinators",
"System Operators",
],
),
(
"developer",
Role::Developer,
&[
"Auditors",
"Content Editors",
"HR Managers",
"Finance",
"Project Coordinators",
"System Operators",
],
),
];
let mut created = 0usize;
for (slug, role, group_names) in demo_specs {
let email = format!("{slug}@{}", branding.domain);
let label = format!("Demo {}", role.label());
match create_demo_user(db, &email, slug, role, Some(&label)).await? {
Some(user_id) => {
created += 1;
for group_name in group_names {
if let Some(group_id) =
crate::auth::permissions::find_group_id_by_name(db, group_name).await?
{
crate::auth::add_user_to_group(db, user_id, group_id).await?;
}
}
}
None => {
log::warn!("RUSTIO_DEMO_MODE: skipping demo user {email} — email already taken");
}
}
}
log::info!("RUSTIO_DEMO_MODE: created {created} demo users (passwords match role slugs)");
Ok(())
}
pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
let row = sqlx::query(
"SELECT id, email, password_hash, role, is_active, is_demo, demo_label
FROM rustio_users
WHERE email = $1",
)
.bind(email)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let r = Row::from_pg(&r);
Ok(Some(StoredUser {
id: r.get_i64("id")?,
email: r.get_string("email")?,
password_hash: r.get_string("password_hash")?,
role: Role::parse(&r.get_string("role")?)?,
is_active: r.get_bool("is_active")?,
is_demo: r.get_bool("is_demo")?,
demo_label: r.get_optional_string("demo_label")?,
}))
}
None => Ok(None),
}
}
pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
let row = sqlx::query(
"SELECT id, email, role, is_active, created_at,
full_name, locale, timezone, is_demo, demo_label
FROM rustio_users
WHERE id = $1",
)
.bind(user_id)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let r = Row::from_pg(&r);
Ok(Some(UserProfile {
id: r.get_i64("id")?,
email: r.get_string("email")?,
role: Role::parse(&r.get_string("role")?)?,
is_active: r.get_bool("is_active")?,
created_at: r.get_datetime("created_at")?,
full_name: r.get_optional_string("full_name")?,
locale: r.get_optional_string("locale")?,
timezone: r.get_optional_string("timezone")?,
is_demo: r.get_bool("is_demo")?,
demo_label: r.get_optional_string("demo_label")?,
}))
}
None => Ok(None),
}
}
pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
let hash = hash_password(new_password)?;
sqlx::query(
"UPDATE rustio_users SET password_hash = $1, updated_at = $2 WHERE id = $3",
)
.bind(&hash)
.bind(Utc::now())
.bind(user_id)
.execute(db.pool())
.await?;
Ok(())
}
pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
sqlx::query(
"UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3",
)
.bind(role.as_str())
.bind(Utc::now())
.bind(user_id)
.execute(db.pool())
.await?;
Ok(())
}
pub async fn would_orphan_developers(
db: &Db,
user_id: i64,
new_role: Option<Role>,
) -> Result<bool> {
if matches!(new_role, Some(Role::Developer)) {
return Ok(false);
}
let active_dev_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_users \
WHERE role = 'developer' AND is_active = TRUE",
)
.fetch_one(db.pool())
.await?;
if active_dev_count == 0 {
return Ok(false);
}
if active_dev_count > 1 {
return Ok(false);
}
let target_role: Option<String> = sqlx::query_scalar(
"SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE",
)
.bind(user_id)
.fetch_optional(db.pool())
.await?;
Ok(target_role.as_deref() == Some("developer"))
}
pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
let user = find_user_by_email(db, email)
.await?
.ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
if !user.is_active {
return Err(Error::Forbidden("account disabled".into()));
}
if !verify_password(password, &user.password_hash) {
return Err(Error::Unauthorized("invalid email or password".into()));
}
create_session(db, user.id).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_profile_derives_debug_and_clone() {
fn assert_traits<T: std::fmt::Debug + Clone>() {}
assert_traits::<UserProfile>();
}
#[test]
fn password_round_trip() {
let h = hash_password("secret").unwrap();
assert!(verify_password("secret", &h));
assert!(!verify_password("wrong", &h));
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn duplicate_email_is_clean_error_message() {
let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
let opts = crate::orm::DbOptions {
max_connections: 2,
..crate::orm::DbOptions::default()
};
let db = crate::orm::Db::connect_with(&url, opts).await.unwrap();
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let tag = format!(
"dup_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let email = format!("{tag}@example.test");
let first_id = create_user(&db, &email, "secret-pw-123", Role::User)
.await
.unwrap();
let err = create_user(&db, &email, "secret-pw-123", Role::User)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("already exists"),
"expected actionable duplicate-email message, got: {msg}"
);
for leaked in [
"rustio_users_email_key",
"duplicate key value",
"constraint",
"SQLSTATE",
"23505",
"Postgres",
"pg::",
] {
assert!(
!msg.contains(leaked),
"client message must NOT contain {leaked:?}, got: {msg}"
);
}
let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(first_id)
.execute(db.pool())
.await;
}
use crate::auth::TEST_ENV_LOCK as ENV_LOCK;
async fn pg_db() -> crate::orm::Db {
let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
.unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
let opts = crate::orm::DbOptions {
max_connections: 2,
..crate::orm::DbOptions::default()
};
crate::orm::Db::connect_with(&url, opts).await.unwrap()
}
async fn reset_demo_state(db: &crate::orm::Db) {
let _ = sqlx::query("DELETE FROM rustio_users WHERE is_demo = TRUE")
.execute(db.pool())
.await;
for name in [
"Auditors",
"Content Editors",
"HR Managers",
"Finance",
"Project Coordinators",
"System Operators",
] {
let _ = sqlx::query("DELETE FROM rustio_groups WHERE name = $1")
.bind(name)
.execute(db.pool())
.await;
}
}
fn test_branding() -> crate::admin::SiteBranding {
crate::admin::SiteBranding {
domain: "rustio.local".into(),
..crate::admin::SiteBranding::default()
}
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn bootstrap_creates_five_demo_users() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
crate::auth::init_permission_tables(&db).await.unwrap();
reset_demo_state(&db).await;
std::env::set_var("RUSTIO_DEMO_MODE", "1");
crate::auth::bootstrap_default_groups(&db).await.unwrap();
bootstrap_demo_users(&db, &test_branding()).await.unwrap();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE \
AND email LIKE '%@rustio.local'",
)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(count, 5, "expected 5 demo users, got {count}");
std::env::remove_var("RUSTIO_DEMO_MODE");
reset_demo_state(&db).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn bootstrap_skips_when_demo_users_already_exist() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
crate::auth::init_permission_tables(&db).await.unwrap();
reset_demo_state(&db).await;
std::env::set_var("RUSTIO_DEMO_MODE", "1");
crate::auth::bootstrap_default_groups(&db).await.unwrap();
bootstrap_demo_users(&db, &test_branding()).await.unwrap();
let first: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE",
)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(first, 5);
bootstrap_demo_users(&db, &test_branding()).await.unwrap();
let second: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE",
)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(first, second, "second bootstrap must NOT add rows");
std::env::remove_var("RUSTIO_DEMO_MODE");
reset_demo_state(&db).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn bootstrap_assigns_groups_correctly() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
crate::auth::init_permission_tables(&db).await.unwrap();
reset_demo_state(&db).await;
std::env::set_var("RUSTIO_DEMO_MODE", "1");
crate::auth::bootstrap_default_groups(&db).await.unwrap();
bootstrap_demo_users(&db, &test_branding()).await.unwrap();
let staff_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_user_groups ug \
JOIN rustio_users u ON u.id = ug.user_id \
WHERE u.email = $1",
)
.bind("staff@rustio.local")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(staff_count, 1, "staff should belong to 1 group");
let admin_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_user_groups ug \
JOIN rustio_users u ON u.id = ug.user_id \
WHERE u.email = $1",
)
.bind("administrator@rustio.local")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(admin_count, 6, "administrator should belong to all 6");
let user_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_user_groups ug \
JOIN rustio_users u ON u.id = ug.user_id \
WHERE u.email = $1",
)
.bind("user@rustio.local")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(user_count, 0, "user has no group memberships");
std::env::remove_var("RUSTIO_DEMO_MODE");
reset_demo_state(&db).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn demo_user_emails_use_branding_domain() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
crate::auth::init_permission_tables(&db).await.unwrap();
reset_demo_state(&db).await;
std::env::set_var("RUSTIO_DEMO_MODE", "1");
crate::auth::bootstrap_default_groups(&db).await.unwrap();
let branding = crate::admin::SiteBranding {
domain: "tolkhuset.test".into(),
..crate::admin::SiteBranding::default()
};
bootstrap_demo_users(&db, &branding).await.unwrap();
let emails: Vec<String> = sqlx::query_scalar(
"SELECT email FROM rustio_users WHERE is_demo = TRUE ORDER BY email",
)
.fetch_all(db.pool())
.await
.unwrap();
assert_eq!(emails.len(), 5);
for e in &emails {
assert!(
e.ends_with("@tolkhuset.test"),
"demo email should use branding domain, got: {e}"
);
}
std::env::remove_var("RUSTIO_DEMO_MODE");
reset_demo_state(&db).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn real_user_unaffected_by_demo_bootstrap() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
crate::auth::init_permission_tables(&db).await.unwrap();
reset_demo_state(&db).await;
let real_email = format!(
"real_{}_{}@example.test",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let real_id = create_user(&db, &real_email, "secret-pw-123", Role::User)
.await
.unwrap();
std::env::set_var("RUSTIO_DEMO_MODE", "1");
crate::auth::bootstrap_default_groups(&db).await.unwrap();
bootstrap_demo_users(&db, &test_branding()).await.unwrap();
let row = find_user_by_email(&db, &real_email).await.unwrap().unwrap();
assert!(!row.is_demo, "real user must NOT be flagged is_demo");
assert_eq!(row.demo_label, None, "real user must NOT have a demo_label");
assert_eq!(row.role, Role::User, "real user's role must be unchanged");
let demo_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_users WHERE is_demo = TRUE",
)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(demo_count, 5);
std::env::remove_var("RUSTIO_DEMO_MODE");
reset_demo_state(&db).await;
let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(real_id)
.execute(db.pool())
.await;
}
async fn make_user(db: &crate::orm::Db, role: Role, is_active: bool) -> i64 {
let email = format!(
"orphan_{}_{}_{}@example.test",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
rand::random::<u32>(),
);
let id = create_user(db, &email, "secret-pw-123", role).await.unwrap();
if !is_active {
sqlx::query("UPDATE rustio_users SET is_active = FALSE WHERE id = $1")
.bind(id)
.execute(db.pool())
.await
.unwrap();
}
id
}
async fn delete_user(db: &crate::orm::Db, id: i64) {
let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(id)
.execute(db.pool())
.await;
}
async fn snapshot_active_devs(db: &crate::orm::Db) -> Vec<i64> {
sqlx::query_scalar(
"SELECT id FROM rustio_users \
WHERE role = 'developer' AND is_active = TRUE",
)
.fetch_all(db.pool())
.await
.unwrap()
}
async fn isolate_developers(db: &crate::orm::Db, keep: &[i64]) -> Vec<i64> {
let snapshot = snapshot_active_devs(db).await;
for id in &snapshot {
if !keep.contains(id) {
sqlx::query("UPDATE rustio_users SET is_active = FALSE WHERE id = $1")
.bind(id)
.execute(db.pool())
.await
.unwrap();
}
}
snapshot
}
async fn restore_active_devs(db: &crate::orm::Db, ids: &[i64]) {
for id in ids {
let _ = sqlx::query("UPDATE rustio_users SET is_active = TRUE WHERE id = $1")
.bind(id)
.execute(db.pool())
.await;
}
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn orphan_when_sole_active_dev_demoted_to_user() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let dev = make_user(&db, Role::Developer, true).await;
let restore = isolate_developers(&db, &[dev]).await;
let orphan = would_orphan_developers(&db, dev, Some(Role::User))
.await
.unwrap();
assert!(orphan, "demoting the sole active developer must orphan");
restore_active_devs(&db, &restore).await;
delete_user(&db, dev).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn no_orphan_when_sole_dev_kept_as_dev() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let dev = make_user(&db, Role::Developer, true).await;
let restore = isolate_developers(&db, &[dev]).await;
let orphan = would_orphan_developers(&db, dev, Some(Role::Developer))
.await
.unwrap();
assert!(!orphan, "Developer → Developer is a no-op, never orphans");
restore_active_devs(&db, &restore).await;
delete_user(&db, dev).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn no_orphan_when_two_active_devs() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let dev_a = make_user(&db, Role::Developer, true).await;
let dev_b = make_user(&db, Role::Developer, true).await;
let restore = isolate_developers(&db, &[dev_a, dev_b]).await;
let orphan_a = would_orphan_developers(&db, dev_a, Some(Role::User))
.await
.unwrap();
let orphan_b = would_orphan_developers(&db, dev_b, Some(Role::Administrator))
.await
.unwrap();
assert!(!orphan_a, "two devs → demoting A leaves B");
assert!(!orphan_b, "two devs → demoting B leaves A");
restore_active_devs(&db, &restore).await;
delete_user(&db, dev_a).await;
delete_user(&db, dev_b).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn inactive_devs_do_not_count() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let active_dev = make_user(&db, Role::Developer, true).await;
let inactive_dev = make_user(&db, Role::Developer, false).await;
let restore = isolate_developers(&db, &[active_dev]).await;
let orphan = would_orphan_developers(&db, active_dev, Some(Role::User))
.await
.unwrap();
assert!(
orphan,
"inactive developers must not satisfy the active-dev requirement"
);
restore_active_devs(&db, &restore).await;
delete_user(&db, active_dev).await;
delete_user(&db, inactive_dev).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn non_developer_target_never_orphans() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let dev = make_user(&db, Role::Developer, true).await;
let staff = make_user(&db, Role::Staff, true).await;
let restore = isolate_developers(&db, &[dev]).await;
let orphan = would_orphan_developers(&db, staff, Some(Role::User))
.await
.unwrap();
assert!(!orphan, "demoting a non-developer can't orphan developers");
restore_active_devs(&db, &restore).await;
delete_user(&db, dev).await;
delete_user(&db, staff).await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn zero_developers_is_not_an_orphan_state() {
let _env = ENV_LOCK.lock().await;
let db = pg_db().await;
crate::auth::init_user_tables(&db).await.unwrap();
crate::auth::migrate_user_schema(&db).await.unwrap();
let restore = isolate_developers(&db, &[]).await;
let staff = make_user(&db, Role::Staff, true).await;
let orphan = would_orphan_developers(&db, staff, Some(Role::User))
.await
.unwrap();
assert!(
!orphan,
"a zero-developer DB is allowed; the guard only kicks in once at least one dev exists"
);
restore_active_devs(&db, &restore).await;
delete_user(&db, staff).await;
}
fn unique_email(tag: &str) -> String {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{tag}_{pid}_{nanos}@example.test")
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn migration_is_idempotent_and_columns_present() {
let db = pg_db().await;
crate::auth::init_tables(&db).await.unwrap();
crate::auth::init_tables(&db).await.unwrap();
let user_cols: Vec<(String,)> = sqlx::query_as(
"SELECT column_name::text FROM information_schema.columns
WHERE table_name = 'rustio_users'
AND column_name IN ('full_name','locale','timezone')
ORDER BY column_name",
)
.fetch_all(db.pool())
.await
.unwrap();
assert_eq!(user_cols.len(), 3, "expected 3 new user columns, got {user_cols:?}");
let session_cols: Vec<(String,)> = sqlx::query_as(
"SELECT column_name::text FROM information_schema.columns
WHERE table_name = 'rustio_sessions'
AND column_name IN ('ip','user_agent')
ORDER BY column_name",
)
.fetch_all(db.pool())
.await
.unwrap();
assert_eq!(session_cols.len(), 2, "expected 2 new session columns, got {session_cols:?}");
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn load_user_profile_happy_path() {
let db = pg_db().await;
crate::auth::init_tables(&db).await.unwrap();
let email = unique_email("profile_happy");
let id = create_user(&db, &email, "secret-pw-123", Role::Staff)
.await
.unwrap();
let profile = load_user_profile(&db, id).await.unwrap().expect("user exists");
assert_eq!(profile.id, id);
assert_eq!(profile.email, email);
assert_eq!(profile.role, Role::Staff);
assert!(profile.is_active);
assert!(profile.full_name.is_none());
assert!(profile.locale.is_none());
assert!(profile.timezone.is_none());
assert!(!profile.is_demo);
assert!(profile.demo_label.is_none());
let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(id)
.execute(db.pool())
.await;
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn load_user_profile_missing_returns_none() {
let db = pg_db().await;
crate::auth::init_tables(&db).await.unwrap();
let result = load_user_profile(&db, 999_999_999).await.unwrap();
assert!(result.is_none(), "missing id must yield Ok(None)");
}
#[tokio::test]
#[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
async fn existing_user_crud_unaffected_by_migration() {
let db = pg_db().await;
crate::auth::init_tables(&db).await.unwrap();
let email = unique_email("crud_smoke");
let id = create_user(&db, &email, "secret-pw-123", Role::User)
.await
.unwrap();
let found = find_user_by_email(&db, &email).await.unwrap().expect("found");
assert_eq!(found.id, id);
set_password(&db, id, "new-secret-456").await.unwrap();
let after = find_user_by_email(&db, &email).await.unwrap().expect("still there");
assert!(verify_password("new-secret-456", &after.password_hash));
let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(id)
.execute(db.pool())
.await;
}
}