//! Instance access: declarative configuration sync, group-derived roles, employee-role //! permissions, bootstrap accounts, administration and SSO sign-in with contact linking. //! Tests that exercise whole-instance state use an isolated database because configuration //! sync disables every workspace that is not configured. mod support; use chrono::{DateTime, Duration, TimeZone, Utc}; use cot::db::{Model, query}; use meguri::{ access::{AuthConfig, BootstrapConfig, Config, WorkspaceConfig}, admin::{self, NewUser}, domain::*, error::Error, identity::{self, BootstrapPasswords, SsoOutcome, SsoProfile, SyncOptions, SyncReport}, models::*, service, store::Store, }; use serde_json::json; use std::collections::BTreeSet; use support::{IsolatedDatabase, account, config_with}; use uuid::Uuid; fn ws(key: &str, name: &str) -> WorkspaceConfig { WorkspaceConfig { key: key.into(), name: name.into(), admin_groups: vec![format!("{key}-leads")], member_groups: vec![format!("{key}-staff")], } } fn at(hour: u32, minute: u32) -> DateTime { Utc.with_ymd_and_hms(2026, 10, 7, hour, minute, 0).unwrap() } async fn try_sync( db: &Store, config: &Config, now: DateTime, ) -> meguri::error::Result { identity::sync_config( db, config, SyncOptions { passwords: BootstrapPasswords::Derived, now, allow_disable_all: false, }, ) .await } async fn sync(db: &Store, config: &Config, now: DateTime) -> SyncReport { try_sync(db, config, now).await.unwrap() } fn set(permissions: Vec) -> BTreeSet { permissions.into_iter().collect() } async fn events(db: &Store, action: &str) -> Vec { InstanceAuditRow::objects() .all(&db.db) .await .unwrap() .into_iter() .filter(|e| e.action == action) .map(|e| serde_json::from_str(&e.details).unwrap()) .collect() } async fn workspace_count(db: &Store, user: Uuid) -> usize { service::list_workspaces(db, user).await.unwrap()["workspaces"] .as_array() .unwrap() .len() } fn profile(subject: &str, hint: &str, email: Option<&str>, groups: &[&str]) -> SsoProfile { SsoProfile { issuer: "https://idp.test/realms/corp".into(), subject: subject.into(), email: email.map(str::to_owned), username_hint: Some(hint.into()), display_name: Some(format!("Display {hint}")), groups: groups.iter().map(|g| (*g).to_owned()).collect(), } } async fn sign_in(db: &Store, config: &Config, p: SsoProfile) -> (User, String) { match identity::sso_sign_in(db, config, p).await.unwrap() { SsoOutcome::SignedIn { user, token, .. } => (*user, token), SsoOutcome::NoAccess => panic!("expected a signed-in user"), } } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn configuration_sync_creates_adopts_renames_disables_and_reenables_without_losing_data() { let iso = IsolatedDatabase::new().await; let db = &iso.store; // A workspace from before configuration existed: no key. let legacy = identity::ensure_workspace(db, &ws("legacy-tmp", "Legacy")) .await .unwrap(); db.db .raw_with( "UPDATE meguri__workspaces SET key=NULL WHERE id=$1", &[&legacy.id.to_string()], ) .await .unwrap(); let mut config = config_with(vec![ ws("legacy-team", "Legacy"), ws("payments", "Payments"), ]); let report = sync(db, &config, at(10, 0)).await; assert_eq!(report.adopted, ["legacy-team"]); assert_eq!(report.created, ["payments"]); let adopted = identity::find_workspace(db, "legacy-team") .await .unwrap() .unwrap(); assert_eq!(adopted.id, legacy.id, "adoption keeps the same workspace"); let payments = identity::find_workspace(db, "payments") .await .unwrap() .unwrap(); let again = sync(db, &config, at(10, 1)).await; assert!(again.created.is_empty() && again.adopted.is_empty() && again.disabled.is_empty()); let lead = account(db, &config, "Lead", &["legacy-team-leads".into()]).await; let employee = account(db, &config, "Employee", &["payments-staff".into()]).await; assert_eq!(workspace_count(db, lead.id).await, 1); assert_eq!(workspace_count(db, employee.id).await, 1); // Renaming in configuration renames the workspace through an ordinary revision. config.workspaces[0].name = "Legacy renamed".into(); let report = sync(db, &config, at(10, 2)).await; assert_eq!(report.renamed, ["legacy-team"]); let renamed = db.workspace(legacy.id).await.unwrap(); assert_eq!(renamed.document.name, "Legacy renamed"); assert_eq!(renamed.revision, adopted.revision + 1); assert!( db.audit(legacy.id) .await .unwrap() .iter() .any(|e| e["action"] == "workspace.renamed") ); // Dropping a workspace from configuration disables it: no access, no publication, data kept. let revision = payments.revision; let kept = config.workspaces.remove(1); let report = sync(db, &config, at(10, 3)).await; assert_eq!(report.disabled, ["payments"]); assert!(matches!( db.workspace(payments.id).await, Err(Error::NotFound) )); assert!(matches!( db.require(payments.id, employee.id, Permission::View).await, Err(Error::NotFound) )); assert_eq!(workspace_count(db, employee.id).await, 0); let id = payments.id.to_string(); assert!( !query!(SyncJobRow,$workspace_id==id.clone()) .exists(&db.db) .await .unwrap(), "no publication for disabled workspaces" ); assert!( !query!(WorkspaceMemberRow,$workspace_id==id.clone()) .exists(&db.db) .await .unwrap() ); let row = query!(WorkspaceRow,$id==id.clone()) .get(&db.db) .await .unwrap() .unwrap(); assert!(row.disabled_at.is_some()); assert!(row.document.contains("\"Payments\"")); // A disabled workspace cannot be committed to, even by an instance administrator. assert!( service::update_document( db, lead.id, payments.id, revision, payments.document.clone() ) .await .is_err() ); let worker_picked = meguri::worker::tick(db).await; assert!(worker_picked.is_ok()); assert!( !query!(SyncJobRow,$workspace_id==id.clone()) .exists(&db.db) .await .unwrap() ); // Putting it back re-enables the same workspace with its members and a pending publication. config.workspaces.push(kept); let report = sync(db, &config, at(10, 4)).await; assert_eq!(report.enabled, ["payments"]); assert_eq!(report.created.len(), 0); assert_eq!(db.workspace(payments.id).await.unwrap().revision, revision); assert_eq!(workspace_count(db, employee.id).await, 1); assert_eq!( db.sync_status(payments.id).await.unwrap()["state"], "pending" ); // Safety rails. let empty = config_with(vec![]); let refused = try_sync(db, &empty, at(10, 5)).await.unwrap_err(); assert!(refused.to_string().contains("Refusing to disable")); assert!( identity::find_workspace(db, "payments") .await .unwrap() .is_some() ); let report = identity::sync_config( db, &empty, SyncOptions { passwords: BootstrapPasswords::Derived, now: at(10, 6), allow_disable_all: true, }, ) .await .unwrap(); assert_eq!(report.disabled.len(), 2); // Two unkeyed workspaces with one name: refuse to guess which one the key means. for key in ["twin-one", "twin-two"] { let twin = identity::ensure_workspace(db, &ws(key, key)).await.unwrap(); db.db .raw_with( "UPDATE meguri__workspaces SET key=NULL,document=jsonb_set(document::jsonb,'{name}','\"Twin\"')::text WHERE id=$1", &[&twin.id.to_string()], ) .await .unwrap(); } let ambiguous = config_with(vec![ws("twin", "Twin")]); let error = try_sync(db, &ambiguous, at(10, 7)).await.unwrap_err(); assert!(error.to_string().contains("adopt exactly one")); for action in [ "workspace.created", "workspace.adopted", "workspace.renamed", "workspace.disabled", "workspace.enabled", ] { assert!( !events(db, action).await.is_empty(), "instance audit: {action}" ); } iso.drop().await; } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn groups_decide_roles_exactly_and_instance_administrators_see_everything() { let iso = IsolatedDatabase::new().await; let db = &iso.store; let mut config = config_with(vec![ws("alpha", "Alpha"), ws("beta", "Beta")]); config.instance_admin_groups = vec!["root".into()]; sync(db, &config, at(9, 0)).await; let alpha = identity::find_workspace(db, "alpha") .await .unwrap() .unwrap(); let beta = identity::find_workspace(db, "beta").await.unwrap().unwrap(); let lead = account( db, &config, "Lead", &["alpha-leads".into(), "beta-staff".into()], ) .await; let staff = account(db, &config, "Staff", &["alpha-staff".into()]).await; let root = account(db, &config, "Root", &["root".into()]).await; let nobody = account(db, &config, "Nobody", &[]).await; identity::apply_access(db, &config, nobody.id, Some(vec!["unrelated".into()])) .await .unwrap(); let near_miss = account(db, &config, "Near", &[]).await; assert_eq!( set(db.permissions(alpha.id, lead.id).await.unwrap()), set(Permission::all()) ); assert_eq!( set(db.permissions(beta.id, lead.id).await.unwrap()), set(Permission::employee_default()), "employees start with calendar, schedule editing and replacement requests" ); assert!(db.owner(alpha.id, lead.id).await.is_ok()); assert!(matches!( db.owner(beta.id, lead.id).await, Err(Error::Forbidden) )); assert!(matches!( db.permissions(beta.id, staff.id).await, Err(Error::NotFound) )); assert_eq!(workspace_count(db, nobody.id).await, 0); // Exact string matching: neither case changes nor prefixes grant anything. for wrong in ["Alpha-Leads", "alpha-lead", "alpha-leads ", "/alpha-leads"] { identity::apply_access(db, &config, near_miss.id, Some(vec![wrong.into()])) .await .unwrap(); assert_eq!(workspace_count(db, near_miss.id).await, 0, "{wrong:?}"); } // Employees cannot administer: integration and drift settings stay with administrators. assert!(matches!( service::set_drift_policy(db, staff.id, alpha.id, alpha.revision, true).await, Err(Error::Forbidden) )); assert!(matches!( service::update_integration(db, staff.id, alpha.id, alpha.revision, Integration::Mock) .await, Err(Error::Forbidden) )); // The instance administrator is not a member but may act, and is audited under their own ID. assert_eq!(workspace_count(db, root.id).await, 2); assert_eq!( set(db.permissions(beta.id, root.id).await.unwrap()), set(Permission::all()) ); assert!(db.owner(beta.id, root.id).await.is_ok()); assert!( !db.members(beta.id) .await .unwrap() .iter() .any(|m| m.user.id == root.id) ); let mut doc = beta.document.clone(); doc.horizon_days = 30; let helped = service::update_document(db, root.id, beta.id, beta.revision, doc) .await .unwrap(); assert_eq!(helped.document.horizon_days, 30); assert_eq!(db.audit(beta.id).await.unwrap()[0]["actor"], root.username); assert!( query!(UserRow,$id==root.id.to_string()) .get(&db.db) .await .unwrap() .unwrap() .instance_admin ); // Roles follow the groups at every change: demotion, promotion and revocation. identity::apply_access(db, &config, lead.id, Some(vec!["alpha-staff".into()])) .await .unwrap(); assert!(matches!( db.owner(alpha.id, lead.id).await, Err(Error::Forbidden) )); assert!(matches!( db.permissions(beta.id, lead.id).await, Err(Error::NotFound) )); identity::apply_access(db, &config, lead.id, Some(vec!["alpha-leads".into()])) .await .unwrap(); assert!(db.owner(alpha.id, lead.id).await.is_ok()); // Administrator wins when one person matches both groups of a workspace. identity::apply_access( db, &config, lead.id, Some(vec!["alpha-staff".into(), "alpha-leads".into()]), ) .await .unwrap(); assert!(db.owner(alpha.id, lead.id).await.is_ok()); identity::apply_access(db, &config, root.id, Some(vec![])) .await .unwrap(); assert!(!db.is_instance_admin(root.id).await.unwrap()); assert!(matches!( db.permissions(alpha.id, root.id).await, Err(Error::NotFound) )); // A removed workspace takes access away from people who are already signed in. config.workspaces.remove(1); sync(db, &config, at(9, 5)).await; assert!(db.permissions(beta.id, staff.id).await.is_err()); iso.drop().await; } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn employee_permissions_belong_to_the_workspace_and_apply_to_everyone_at_once() { let f = support::Fixture::new().await; let (lead, bob, carol) = (f.people[0].id, f.people[1].id, f.people[2].id); let team = f.team.id; // Fixture employees start with View + RequestOverrides. assert!( !f.db .permissions(team, bob) .await .unwrap() .contains(&Permission::ForceOverrides) ); // Only administrators change the role; invalid sets are refused. assert!(matches!( service::set_member_permissions( &f.db, bob, team, f.team.revision, vec![Permission::View, Permission::ForceOverrides] ) .await, Err(Error::Forbidden) )); for invalid in [ vec![Permission::RequestOverrides], vec![Permission::View, Permission::ManageMembers], vec![Permission::View, Permission::ManageIntegration], ] { assert!( service::set_member_permissions(&f.db, lead, team, f.team.revision, invalid) .await .is_err() ); } assert!(matches!( service::set_member_permissions( &f.db, lead, team, f.team.revision - 1, vec![Permission::View] ) .await, Err(Error::Conflict(_)) )); // Without the grant an employee cannot force a replacement. let force = |revision| service::NewRequest { remaining: false, revision, reason: "Forced".into(), legs: vec![f.leg()], force: true, }; assert!(matches!( service::request_replacement(&f.db, bob, team, force(f.team.revision)).await, Err(Error::Forbidden) )); let updated = service::set_member_permissions( &f.db, lead, team, f.team.revision, vec![ Permission::ForceOverrides, Permission::View, Permission::RequestOverrides, Permission::View, ], ) .await .unwrap(); assert_eq!( set(updated.document.member_permissions.clone()), set(vec![ Permission::View, Permission::RequestOverrides, Permission::ForceOverrides ]) ); for employee in [bob, carol] { assert!( f.db.permissions(team, employee) .await .unwrap() .contains(&Permission::ForceOverrides), "the role applies to every current employee immediately" ); } // The administrator keeps everything, and a later employee gets the updated role. assert_eq!( set(f.db.permissions(team, lead).await.unwrap()), set(Permission::all()) ); let workspace = f.config.workspaces[0].clone(); let newcomer = account(&f.db, &f.config, "Newcomer", &workspace.member_groups).await; assert!( f.db.permissions(team, newcomer.id) .await .unwrap() .contains(&Permission::ForceOverrides) ); let applied = service::request_replacement(&f.db, bob, team, force(updated.revision)) .await .unwrap(); assert_eq!( applied.document.requests.last().unwrap().status, RequestStatus::Applied ); let audit = f.db.audit(team).await.unwrap(); assert!(audit.iter().any(|e| { e["action"] == "member.role_permissions_changed" && e["actor"] == f.people[0].username })); // Taking the grant away is just as immediate. let reduced = service::set_member_permissions( &f.db, lead, team, applied.revision, vec![Permission::View], ) .await .unwrap(); assert_eq!( f.db.permissions(team, bob).await.unwrap(), vec![Permission::View] ); assert!(reduced.revision > applied.revision); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn bootstrap_accounts_share_an_hourly_password_and_disappear_with_the_configuration() { let iso = IsolatedDatabase::new().await; let db = &iso.store; let mut config = Config { auth: AuthConfig { password_login: false, bootstrap_admin: Some(BootstrapConfig { usernames: vec!["rescue".into()], }), }, oidc: None, instance_admin_groups: vec![], workspaces: vec![ws("alpha", "Alpha")], }; let first = sync(db, &config, at(14, 10)).await; let (name, password) = first.bootstrap_credentials[0].clone(); assert_eq!(name, "rescue"); assert!(password.len() >= 24); // Bootstrap access works even though ordinary password sign-in is off, and is full access. let (user, token, _) = service::login(db, false, "rescue".into(), password.clone()) .await .unwrap(); assert!(user.instance_admin); assert!(db.is_instance_admin(user.id).await.unwrap()); assert!( service::login(db, false, "rescue".into(), "wrong-password-0000".into()) .await .is_err() ); let other = account(db, &config, "Worker", &[]).await; assert!(matches!( service::login( db, false, other.username.clone(), "test-only-password".into() ) .await, Err(Error::Unauthorized) )); assert!( service::login( db, true, other.username.clone(), "test-only-password".into() ) .await .is_ok() ); // Same UTC hour: the same password, nothing rotated, sessions survive. let same = sync(db, &config, at(14, 55)).await; assert_eq!(same.bootstrap_credentials[0].1, password); assert!(same.bootstrap_rotated.is_empty()); let hash = service::token_hash(&token); assert!( query!(SessionRow,$token_hash==hash.clone()) .exists(&db.db) .await .unwrap() ); // Next hour, two replicas starting together agree with each other and rotate once. let (a, b) = tokio::join!( try_sync(db, &config, at(15, 1)), try_sync(db, &config, at(15, 2)) ); let (a, b) = (a.unwrap(), b.unwrap()); assert_eq!(a.bootstrap_credentials, b.bootstrap_credentials); let next = a.bootstrap_credentials[0].1.clone(); assert_ne!(next, password); assert!( service::login(db, false, "rescue".into(), password.clone()) .await .is_err(), "the previous hour's password stops working" ); assert!( !query!(SessionRow,$token_hash==hash.clone()) .exists(&db.db) .await .unwrap(), "rotation signs the account out" ); let (_, session, _) = service::login(db, false, "rescue".into(), next.clone()) .await .unwrap(); // An SSO account is never taken over by a bootstrap name. let (sso, _) = sign_in( db, &{ let mut c = config.clone(); c.instance_admin_groups = vec!["root".into()]; c }, profile("sub-claimed", "claimed", None, &["root"]), ) .await; assert_eq!(sso.username, "claimed"); let mut hostile = config.clone(); hostile.auth.bootstrap_admin = Some(BootstrapConfig { usernames: vec!["claimed".into()], }); assert!( try_sync(db, &hostile, at(15, 3)) .await .unwrap_err() .to_string() .contains("SSO") ); // Removing the block locks the account out completely but keeps the user record. config.auth.bootstrap_admin = None; config.auth.password_login = true; let report = sync(db, &config, at(15, 4)).await; assert_eq!(report.bootstrap_released, ["rescue"]); assert!(report.bootstrap_credentials.is_empty()); assert!( service::login(db, true, "rescue".into(), next) .await .is_err() ); let row = query!(UserRow,$username=="rescue".to_owned()) .get(&db.db) .await .unwrap() .unwrap(); assert!(!row.bootstrap && !row.instance_admin && row.password_hash.is_none()); let session_hash = service::token_hash(&session); assert!( !query!(SessionRow,$token_hash==session_hash) .exists(&db.db) .await .unwrap() ); iso.drop().await; } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn only_instance_administrators_manage_password_users_and_delete_disabled_workspaces() { let iso = IsolatedDatabase::new().await; let db = &iso.store; let mut config = config_with(vec![ws("alpha", "Alpha"), ws("beta", "Beta")]); config.instance_admin_groups = vec!["root".into()]; sync(db, &config, at(9, 0)).await; let root = account(db, &config, "Root", &["root".into()]).await; let plain = account(db, &config, "Plain", &["alpha-staff".into()]).await; let new_user = |name: &str| NewUser { username: name.into(), display_name: "Created".into(), password: "long-enough-password".into(), email: None, groups: vec![], country: None, }; // Nothing is available to ordinary users, not even read access to the overview. assert!(matches!( admin::overview(db, &config, plain.id).await, Err(Error::Forbidden) )); assert!(matches!( admin::create_user(db, &config, plain.id, new_user("sneaky")).await, Err(Error::Forbidden) )); assert!(matches!( admin::set_user_groups(db, &config, plain.id, root.id, vec![]).await, Err(Error::Forbidden) )); assert!(matches!( admin::reset_password(db, plain.id, root.id, "another-long-password".into()).await, Err(Error::Forbidden) )); let alpha = identity::find_workspace(db, "alpha") .await .unwrap() .unwrap(); assert!(matches!( admin::delete_workspace(db, &config, plain.id, alpha.id, "alpha").await, Err(Error::Forbidden) )); let overview = admin::overview(db, &config, root.id).await.unwrap(); assert_eq!(overview["workspaces"].as_array().unwrap().len(), 2); assert_eq!(overview["workspaces"][0]["in_config"], true); assert_eq!(overview["workspaces"][0]["admin_groups"][0], "alpha-leads"); assert_eq!(overview["workspaces"][0]["member_count"], 1); assert_eq!(overview["known_groups"].as_array().unwrap().len(), 5); assert!( overview["users"] .as_array() .unwrap() .iter() .any(|u| u["username"] == root.username && u["instance_admin"] == true) ); // Creation validates everything and applies groups through the same access path. for bad in [ NewUser { username: "A B".into(), ..new_user("x") }, NewUser { email: Some("not-an-email".into()), ..new_user("emailbad") }, NewUser { groups: vec!["not-configured".into()], ..new_user("groupbad") }, NewUser { password: "short".into(), ..new_user("shortpw") }, ] { assert!(matches!( admin::create_user(db, &config, root.id, bad).await, Err(Error::Validation(_)) )); } let created = admin::create_user( db, &config, root.id, NewUser { email: Some(" Casey@Example.com ".into()), groups: vec!["beta-leads".into(), "alpha-staff".into()], ..new_user("casey") }, ) .await .unwrap(); let casey: User = serde_json::from_value(created["user"].clone()).unwrap(); assert_eq!(casey.email.as_deref(), Some("casey@example.com")); assert!(casey.has_password && !casey.instance_admin); assert!(matches!( admin::create_user(db, &config, root.id, new_user("casey")).await, Err(Error::Conflict(_)) )); let beta = identity::find_workspace(db, "beta").await.unwrap().unwrap(); assert!(db.owner(beta.id, casey.id).await.is_ok()); assert!( service::login(db, true, "casey".into(), "long-enough-password".into()) .await .is_ok() ); // Editing groups: password users only, from the configured list, never yourself. admin::set_user_groups(db, &config, root.id, casey.id, vec!["alpha-leads".into()]) .await .unwrap(); assert!(db.owner(alpha.id, casey.id).await.is_ok()); assert!(db.permissions(beta.id, casey.id).await.is_err()); assert!( admin::set_user_groups(db, &config, root.id, casey.id, vec!["made-up".into()]) .await .is_err() ); assert!( admin::set_user_groups(db, &config, root.id, root.id, vec![]) .await .is_err(), "an administrator cannot edit their own access" ); let (sso, _) = sign_in( db, &config, profile("sso-1", "ssouser", None, &["alpha-staff"]), ) .await; assert!( admin::set_user_groups(db, &config, root.id, sso.id, vec!["alpha-leads".into()]) .await .is_err(), "SSO groups belong to the identity provider" ); assert!( admin::reset_password(db, root.id, sso.id, "another-long-password".into()) .await .is_err() ); // Password reset ends existing sessions and replaces the credential. let (_, token, _) = service::login(db, true, "casey".into(), "long-enough-password".into()) .await .unwrap(); admin::reset_password(db, root.id, casey.id, "brand-new-password".into()) .await .unwrap(); let hash = service::token_hash(&token); assert!( !query!(SessionRow,$token_hash==hash) .exists(&db.db) .await .unwrap() ); assert!( service::login(db, true, "casey".into(), "long-enough-password".into()) .await .is_err() ); assert!( service::login(db, true, "casey".into(), "brand-new-password".into()) .await .is_ok() ); assert!( admin::reset_password(db, root.id, casey.id, "short".into()) .await .is_err() ); // Deleting: only a disabled workspace that left the configuration, confirmed by its key. assert!( admin::delete_workspace(db, &config, root.id, beta.id, "beta") .await .is_err(), "enabled workspaces cannot be deleted" ); let removed = config.workspaces.remove(1); sync(db, &config, at(9, 30)).await; assert!( admin::delete_workspace(db, &config, root.id, beta.id, "Beta") .await .is_err(), "the confirmation must be the exact key" ); let id = beta.id.to_string(); assert!( query!(WorkspaceRow,$id==id.clone()) .exists(&db.db) .await .unwrap() ); admin::delete_workspace(db, &config, root.id, beta.id, "beta") .await .unwrap(); assert!( !query!(WorkspaceRow,$id==id.clone()) .exists(&db.db) .await .unwrap() ); for table in [ "meguri__workspace_history", "meguri__audit_events", "meguri__sync_jobs", "meguri__workspace_members", ] { db.db .raw_with( &format!( "DO $$ BEGIN IF EXISTS(SELECT 1 FROM {table} WHERE workspace_id='{id}') THEN RAISE EXCEPTION 'orphans in {table}'; END IF; END $$" ), &[], ) .await .unwrap(); } assert_eq!(events(db, "workspace.deleted").await[0]["key"], "beta"); assert!(matches!( admin::delete_workspace(db, &config, root.id, beta.id, "beta").await, Err(Error::NotFound) )); assert!( admin::delete_workspace(db, &config, root.id, alpha.id, "alpha") .await .is_err(), "configured workspaces are never deletable" ); // Declaring the key again starts an entirely new workspace. config.workspaces.push(removed); let report = sync(db, &config, at(9, 40)).await; assert_eq!(report.created, ["beta"]); assert_ne!( identity::find_workspace(db, "beta") .await .unwrap() .unwrap() .id, beta.id ); for action in ["user.created", "user.groups_changed", "user.password_reset"] { assert!(!events(db, action).await.is_empty(), "{action}"); } iso.drop().await; } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn sso_sign_in_creates_accounts_from_groups_and_revokes_them_with_the_groups() { let iso = IsolatedDatabase::new().await; let db = &iso.store; let mut config = config_with(vec![ws("alpha", "Alpha")]); config.instance_admin_groups = vec!["root".into()]; sync(db, &config, at(9, 0)).await; let alpha = identity::find_workspace(db, "alpha") .await .unwrap() .unwrap(); let users_before = UserRow::objects().all(&db.db).await.unwrap().len(); // Unmapped people leave nothing behind. assert!(matches!( identity::sso_sign_in(db, &config, profile("s0", "nobody", None, &["other"])) .await .unwrap(), SsoOutcome::NoAccess )); assert!(matches!( identity::sso_sign_in(db, &config, profile("s0", "nobody", None, &[])) .await .unwrap(), SsoOutcome::NoAccess )); assert_eq!( UserRow::objects().all(&db.db).await.unwrap().len(), users_before ); // First sign-in creates the account with a derived unique username and a working session. taken_username(db, &config).await; let (dana, token) = sign_in( db, &config, profile("s1", "dana.smith", Some("dana@corp.test"), &["alpha-staff"]), ) .await; assert_ne!( dana.username, "dana.smith", "a taken username gets a suffix" ); assert!(dana.username.starts_with("dana.smith-")); assert!(!dana.has_password && dana.email.as_deref() == Some("dana@corp.test")); assert_eq!(dana.display_name, "Display dana.smith"); let hash = service::token_hash(&token); assert!( query!(SessionRow,$token_hash==hash) .exists(&db.db) .await .unwrap() ); assert!( service::login(db, true, dana.username.clone(), "anything-at-all-1".into()) .await .is_err(), "SSO accounts have no password" ); assert_eq!( set(db.permissions(alpha.id, dana.id).await.unwrap()), set(Permission::employee_default()) ); // The same subject is the same account; group changes apply at the next sign-in. let (again, _) = sign_in( db, &config, profile( "s1", "renamed.in.idp", Some("dana@corp.test"), &["alpha-leads"], ), ) .await; assert_eq!(again.id, dana.id); assert_eq!(again.username, dana.username); assert!(db.owner(alpha.id, dana.id).await.is_ok()); let identities = ExternalIdentityRow::objects().all(&db.db).await.unwrap(); assert_eq!(identities.iter().filter(|i| i.subject == "s1").count(), 1); // Revoked in the provider: refused, memberships gone, nothing else survives. assert!(matches!( identity::sso_sign_in( db, &config, profile("s1", "dana.smith", None, &["unrelated"]) ) .await .unwrap(), SsoOutcome::NoAccess )); assert!(db.permissions(alpha.id, dana.id).await.is_err()); assert_eq!(workspace_count(db, dana.id).await, 0); let row = query!(UserRow,$id==dana.id.to_string()) .get(&db.db) .await .unwrap() .unwrap(); assert_eq!(row.groups, "[\"unrelated\"]"); // Instance administrators can sign in with no workspace at all. let (root, _) = sign_in(db, &config, profile("s2", "root.user", None, &["root"])).await; assert!(root.instance_admin); assert_eq!( workspace_count(db, root.id).await, 1, "sees every workspace" ); assert!( !db.members(alpha.id) .await .unwrap() .iter() .any(|m| m.user.id == root.id) ); // Concurrent first sign-ins of one identity converge on a single account. let p = profile("s3", "racer", None, &["alpha-staff"]); let (left, right) = tokio::join!( identity::sso_sign_in(db, &config, p.clone()), identity::sso_sign_in(db, &config, p) ); let (left, right) = (left.unwrap(), right.unwrap()); let (SsoOutcome::SignedIn { user: l, .. }, SsoOutcome::SignedIn { user: r, .. }) = (left, right) else { panic!("both sign-ins must succeed"); }; assert_eq!(l.id, r.id); let racers = ExternalIdentityRow::objects() .all(&db.db) .await .unwrap() .into_iter() .filter(|i| i.subject == "s3") .count(); assert_eq!(racers, 1); iso.drop().await; } async fn taken_username(db: &Store, config: &Config) { admin::create_password_user( db, config, None, NewUser { username: "dana.smith".into(), display_name: "Existing".into(), password: "long-enough-password".into(), email: None, groups: vec![], country: None, }, ) .await .unwrap(); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_access_by_email() { let iso = IsolatedDatabase::new().await; let db = &iso.store; let config = config_with(vec![ws("alpha", "Alpha"), ws("beta", "Beta")]); sync(db, &config, at(9, 0)).await; let lead = account( db, &config, "Lead", &["alpha-leads".into(), "beta-leads".into()], ) .await; let alpha = identity::find_workspace(db, "alpha") .await .unwrap() .unwrap(); let beta = identity::find_workspace(db, "beta").await.unwrap().unwrap(); // The lead plans the whole team in advance, using the colleague's email only. let contact = Uuid::new_v4(); let other_contact = Uuid::new_v4(); let schedule = Uuid::new_v4(); let day = Utc::now().date_naive() + Duration::days(2); let mut doc = alpha.document.clone(); doc.timezone = "UTC".into(); doc.people.push(Person { id: contact, user_id: None, display_name: "Dana Contact".into(), email: Some("dana@corp.test".into()), country: Some("AM".into()), aliases: vec![], }); doc.schedules.push(Schedule { id: schedule, name: "General".into(), handover: default_handover(), kind: ScheduleKind::Rotation { participants: vec![lead.id, contact], anchor: day, shift_days: 7, }, }); doc.absences.push(Absence { id: Uuid::new_v4(), user_id: contact, start: day + Duration::days(30), end: day + Duration::days(31), }); let planned = service::update_document(db, lead.id, alpha.id, alpha.revision, doc) .await .unwrap(); // The same email in a workspace where Dana has no access must stay untouched. let mut beta_doc = beta.document.clone(); beta_doc.people.push(Person { id: other_contact, user_id: None, display_name: "Dana elsewhere".into(), email: Some("dana@corp.test".into()), country: None, aliases: vec![], }); let beta_planned = service::update_document(db, lead.id, beta.id, beta.revision, beta_doc) .await .unwrap(); // An email is not access: nobody is a member yet. assert!( !db.members(alpha.id) .await .unwrap() .iter() .any(|m| m.user.display_name.contains("Dana")) ); let (dana, _) = sign_in( db, &config, profile("sd", "dana", Some("dana@corp.test"), &["alpha-staff"]), ) .await; let linked = db.workspace(alpha.id).await.unwrap(); assert_eq!(linked.revision, planned.revision + 1); assert!(linked.document.people.iter().all(|p| p.id != contact)); let person = linked .document .people .iter() .find(|p| p.id == dana.id) .unwrap(); assert_eq!(person.user_id, Some(dana.id)); assert_eq!(person.aliases, vec![contact]); assert_eq!( person.display_name, "Dana Contact", "the lead's data is kept" ); assert_eq!(person.country.as_deref(), Some("AM")); assert_eq!(person.email.as_deref(), Some("dana@corp.test")); match &linked.document.schedules[0].kind { ScheduleKind::Rotation { participants, .. } => { assert_eq!(participants, &vec![lead.id, dana.id]); } _ => panic!("rotation expected"), } assert_eq!(linked.document.absences[0].user_id, dana.id); // The prepared schedule works for her immediately: she is a member and owns her shift. let timeline = meguri::engine::timeline( &linked.document, meguri::engine::handover(&linked.document, day + Duration::days(7)).unwrap(), meguri::engine::handover(&linked.document, day + Duration::days(8)).unwrap(), ) .unwrap(); assert_eq!(timeline.assignments[0].user_id, dana.id); // Other workspaces are untouched, history keeps the old identifier, and audit still names her. assert_eq!( db.workspace(beta.id).await.unwrap().revision, beta_planned.revision ); let history = query!(HistoryRow,$workspace_id==alpha.id.to_string()) .all(&db.db) .await .unwrap(); let before = history .iter() .find(|h| h.revision == planned.revision) .unwrap(); assert!(before.document.contains(&contact.to_string())); let audit = db.audit(alpha.id).await.unwrap(); let event = audit .iter() .find(|e| e["action"] == "person.linked") .unwrap(); assert_eq!(event["actor"], dana.username); assert_eq!( event["resolved_details"]["contact_id"]["name"], "Dana Contact" ); // Signing in again changes nothing. sign_in( db, &config, profile("sd", "dana", Some("DANA@corp.test"), &["alpha-staff"]), ) .await; assert_eq!( db.workspace(alpha.id).await.unwrap().revision, linked.revision ); // A colleague nobody planned still becomes a person with an email, ready for providers. let (erin, _) = sign_in( db, &config, profile("se", "erin", Some("erin@corp.test"), &["alpha-staff"]), ) .await; let alpha_now = db.workspace(alpha.id).await.unwrap(); let person = alpha_now .document .people .iter() .find(|p| p.id == erin.id) .unwrap(); assert_eq!(person.email.as_deref(), Some("erin@corp.test")); // No email, no person entry, still a member. let (frank, _) = sign_in(db, &config, profile("sf", "frank", None, &["alpha-staff"])).await; assert!(db.permissions(alpha.id, frank.id).await.is_ok()); assert!( !db.workspace(alpha.id) .await .unwrap() .document .people .iter() .any(|p| p.id == frank.id) ); iso.drop().await; } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL"] async fn employees_plan_schedules_but_only_administrators_change_preferences_and_the_directory() { let f = support::Fixture::new().await; let (lead, bob) = (f.people[0].id, f.people[1].id); let team = f.team.id; // Employees may edit schedules here, as with the default employee permission set. let record = service::set_member_permissions( &f.db, lead, team, f.team.revision, Permission::employee_default(), ) .await .unwrap(); let contact = Uuid::new_v4(); let mut doc = record.document.clone(); doc.people.push(Person { id: contact, user_id: None, display_name: "Dana Contact".into(), email: Some("dana@corp.test".into()), country: Some("AM".into()), aliases: vec![], }); let record = service::update_document(&f.db, lead, team, record.revision, doc) .await .unwrap(); let try_edit = |change: &dyn Fn(&mut WorkspaceDocument)| { let mut doc = record.document.clone(); change(&mut doc); service::update_document(&f.db, bob, team, record.revision, doc) }; // Workspace preferences and existing directory entries are administrator-only. for (label, change) in [ ( "name", &(|d: &mut WorkspaceDocument| d.name = "Renamed".into()) as &dyn Fn(&mut WorkspaceDocument), ), ("horizon", &|d| d.horizon_days = 30), ("person name", &|d| { d.people[0].display_name = "Someone else".into() }), ("person email", &|d| { d.people[0].email = Some("other@corp.test".into()) }), ("person country", &|d| { d.people[0].country = Some("CY".into()) }), ("person removal", &|d| d.people.clear()), ] { assert!( matches!(try_edit(change).await, Err(Error::Forbidden)), "an employee must not change the {label}" ); } // Planning is allowed, including new email contacts and unchanged people sent back as is. let planned = try_edit(&|d| { d.schedules[0].name = "Weekly rotation".into(); d.people.push(Person { id: Uuid::new_v4(), user_id: None, display_name: "New contact".into(), email: Some("new@corp.test".into()), country: None, aliases: vec![], }); d.people[0].email = Some(" DANA@corp.test ".into()); }) .await .unwrap(); assert_eq!(planned.document.schedules[0].name, "Weekly rotation"); assert_eq!(planned.document.name, record.document.name); assert_eq!(planned.document.people.len(), 2); // The workspace administrator still changes both. let mut doc = planned.document.clone(); doc.name = "Administrators rename".into(); doc.horizon_days = 45; doc.people[0].display_name = "Dana Renamed".into(); let changed = service::update_document(&f.db, lead, team, planned.revision, doc) .await .unwrap(); assert_eq!(changed.document.name, "Administrators rename"); assert_eq!(changed.document.horizon_days, 45); assert_eq!(changed.document.people[0].display_name, "Dana Renamed"); } #[tokio::test] #[ignore = "requires MEGURI_TEST_DATABASE_URL and CREATEDB"] async fn the_instance_event_log_is_searchable_newest_first_and_failed_logins_are_capped() { let iso = IsolatedDatabase::new().await; let db = &iso.store; let mut config = config_with(vec![ws("alpha", "Alpha")]); config.instance_admin_groups = vec!["root".into()]; sync(db, &config, at(9, 0)).await; let root = account(db, &config, "Root", &["root".into()]).await; let plain = account(db, &config, "Plain", &["alpha-staff".into()]).await; assert!(matches!( admin::events(db, plain.id, "").await, Err(Error::Forbidden) )); db.instance_event( None, "login.failed", json!({"reference":"deadbeef","code":"no_access","groups":["/unknown"]}), ) .await .unwrap(); admin::create_user( db, &config, root.id, NewUser { username: "casey".into(), display_name: "Casey".into(), password: "long-enough-password".into(), email: None, groups: vec![], country: None, }, ) .await .unwrap(); let all = admin::events(db, root.id, "").await.unwrap(); let actions: Vec<_> = all["events"] .as_array() .unwrap() .iter() .map(|e| e["action"].as_str().unwrap().to_owned()) .collect(); assert_eq!(actions[0], "user.created", "newest first"); assert!(actions.contains(&"workspace.created".to_owned())); assert_eq!(all["events"][0]["actor"], root.username); let by_reference = admin::events(db, root.id, " DEADBEEF ").await.unwrap(); assert_eq!(by_reference["events"].as_array().unwrap().len(), 1); assert_eq!( by_reference["events"][0]["details"]["groups"][0], "/unknown" ); assert!( admin::events(db, root.id, "no-such-text").await.unwrap()["events"] .as_array() .unwrap() .is_empty() ); // Anonymous sign-in failures cannot grow the table without bound, nor push out other events. for n in 0..2010 { db.instance_event(None, "login.failed", json!({"reference":format!("{n:08}")})) .await .unwrap(); } let remaining = InstanceAuditRow::objects() .all(&db.db) .await .unwrap() .into_iter() .filter(|e| e.action == "login.failed") .count(); assert!((2000..=2002).contains(&remaining), "{remaining}"); assert!( !events(db, "workspace.created").await.is_empty() && !events(db, "user.created").await.is_empty() ); iso.drop().await; }