Files
meguri/tests/access.rs
T

1359 lines
44 KiB
Rust
Raw Normal View History

//! 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 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> {
Utc.with_ymd_and_hms(2026, 10, 7, hour, minute, 0).unwrap()
}
async fn try_sync(
db: &Store,
config: &Config,
now: DateTime<Utc>,
) -> meguri::error::Result<SyncReport> {
identity::sync_config(
db,
config,
SyncOptions {
passwords: BootstrapPasswords::Derived,
now,
allow_disable_all: false,
},
)
.await
}
async fn sync(db: &Store, config: &Config, now: DateTime<Utc>) -> SyncReport {
try_sync(db, config, now).await.unwrap()
}
fn set(permissions: Vec<Permission>) -> BTreeSet<Permission> {
permissions.into_iter().collect()
}
async fn events(db: &Store, action: &str) -> Vec<serde_json::Value> {
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![],
2026-10-07 14:22:37 +01:00
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![],
2026-10-07 14:22:37 +01:00
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;
}
2026-10-07 14:22:37 +01:00
#[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");
}