Files
meguri/tests/access.rs
T
Aleksandr BogomiakovandClaude 548494f12e Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log
Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 15:06:12 +01:00

1440 lines
47 KiB
Rust

//! 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> {
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![],
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;
}