NEXT predates the access work, so the identity layer follows master: access comes from configured groups, instance administrators replace the site administration, and accounts are created by SSO or by an administrator. Kept from NEXT: manual publication with the Review changes panel and shared generator drafts, portable import/export (meguri/v2), the layered generation map with binding provenance, JSM provider changes, contact/approval semantics and the account-owned Person. Dropped: site administration, local sign-up, and the mandatory unique email with its contact/account migrations. The manual-publication migration is renumbered m_0010 after m_0009_access. Accounts without an email still get a Person in every workspace they reach, a changed sign-in email updates the account's person, and an account that loses access keeps its linked person so schedules stay valid. Instance administrators may use Sync and shared drafts. Co-Authored-By: Claude <noreply@anthropic.com>
1542 lines
50 KiB
Rust
1542 lines
50 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 person_mut(document: &mut WorkspaceDocument, id: Uuid) -> &mut Person {
|
|
document.people.iter_mut().find(|p| p.id == id).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.
|
|
let before_rename = db.workspace(legacy.id).await.unwrap().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, before_rename + 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.
|
|
let revision = query!(WorkspaceRow,$id==id.clone())
|
|
.get(&db.db)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.revision;
|
|
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"], "draft");
|
|
|
|
// 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 beta = db.workspace(beta.id).await.unwrap();
|
|
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)
|
|
);
|
|
// Adding the newcomer also added their person to the workspace: take the current revision.
|
|
let current = f.db.workspace(team).await.unwrap();
|
|
let applied = service::request_replacement(&f.db, bob, team, force(current.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
|
|
);
|
|
|
|
// The linked person is an account person: with the next ordinary commit the account profile
|
|
// (provider display name, default country) becomes authoritative, while the lead's contact
|
|
// data was kept until then. The email, ID and alias stay.
|
|
let committed = service::update_document(
|
|
db,
|
|
lead.id,
|
|
alpha.id,
|
|
linked.revision,
|
|
linked.document.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let person = committed
|
|
.document
|
|
.people
|
|
.iter()
|
|
.find(|p| p.id == dana.id)
|
|
.unwrap();
|
|
assert_eq!(person.display_name, dana.display_name);
|
|
assert_eq!(person.country.as_deref(), Some("CY"));
|
|
assert_eq!(person.email.as_deref(), Some("dana@corp.test"));
|
|
assert_eq!(person.aliases, vec![contact]);
|
|
|
|
// 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: still a member, with a person of their own and nothing to link.
|
|
let (frank, _) = sign_in(db, &config, profile("sf", "frank", None, &["alpha-staff"])).await;
|
|
assert!(db.permissions(alpha.id, frank.id).await.is_ok());
|
|
let frank_person = db
|
|
.workspace(alpha.id)
|
|
.await
|
|
.unwrap()
|
|
.document
|
|
.people
|
|
.into_iter()
|
|
.find(|p| p.id == frank.id)
|
|
.unwrap();
|
|
assert!(frank_person.email.is_none() && frank_person.user_id == Some(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| {
|
|
person_mut(d, contact).display_name = "Someone else".into()
|
|
}),
|
|
("person email", &|d| {
|
|
person_mut(d, contact).email = Some("other@corp.test".into())
|
|
}),
|
|
("person country", &|d| {
|
|
person_mut(d, contact).country = Some("CY".into())
|
|
}),
|
|
("person removal", &|d| d.people.retain(|p| p.id != contact)),
|
|
] {
|
|
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![],
|
|
});
|
|
person_mut(d, contact).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!(
|
|
planned
|
|
.document
|
|
.people
|
|
.iter()
|
|
.any(|p| p.id != contact && p.user_id.is_none())
|
|
);
|
|
// The workspace administrator still changes both.
|
|
let mut doc = planned.document.clone();
|
|
doc.name = "Administrators rename".into();
|
|
doc.horizon_days = 45;
|
|
person_mut(&mut doc, contact).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.person(contact).unwrap().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;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires MEGURI_TEST_DATABASE_URL"]
|
|
async fn a_departed_member_keeps_a_valid_person_and_is_a_member_again_after_rejoining() {
|
|
let f = support::Fixture::new().await;
|
|
let (lead, bob) = (f.people[0].id, f.people[1].id);
|
|
let team = f.team.id;
|
|
let workspace = f.config.workspaces[0].clone();
|
|
// The person of every member exists in the workspace once it is edited.
|
|
let record =
|
|
service::update_document(&f.db, lead, team, f.team.revision, f.team.document.clone())
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
record
|
|
.document
|
|
.people
|
|
.iter()
|
|
.any(|p| p.id == bob && p.user_id == Some(bob))
|
|
);
|
|
// Access follows groups: bob leaves; his person (and every schedule reference) stays valid.
|
|
identity::apply_access(&f.db, &f.config, bob, Some(vec![]))
|
|
.await
|
|
.unwrap();
|
|
let after =
|
|
service::update_document(&f.db, lead, team, record.revision, record.document.clone())
|
|
.await
|
|
.unwrap();
|
|
let person = after.document.people.iter().find(|p| p.id == bob).unwrap();
|
|
assert_eq!(
|
|
person.user_id,
|
|
Some(bob),
|
|
"the account link survives the departure"
|
|
);
|
|
assert!(
|
|
f.db.member_ids(team)
|
|
.await
|
|
.unwrap()
|
|
.iter()
|
|
.all(|id| *id != bob)
|
|
);
|
|
assert!(f.db.permissions(team, bob).await.is_err());
|
|
// Returning only restores access; the person is already linked.
|
|
identity::apply_access(&f.db, &f.config, bob, Some(workspace.member_groups.clone()))
|
|
.await
|
|
.unwrap();
|
|
let current = f.db.workspace(team).await.unwrap();
|
|
let back = service::update_document(
|
|
&f.db,
|
|
lead,
|
|
team,
|
|
current.revision,
|
|
current.document.clone(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let person = back.document.people.iter().find(|p| p.id == bob).unwrap();
|
|
assert_eq!(person.user_id, Some(bob));
|
|
}
|