Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
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>
This commit is contained in:
commit
38105c65c2
57 files changed
+6890
-431
No files matched your search
+127
-25
@@ -50,6 +50,9 @@ async fn try_sync(
|
||||
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()
|
||||
}
|
||||
@@ -127,12 +130,13 @@ async fn configuration_sync_creates_adopts_renames_disables_and_reenables_withou
|
||||
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, adopted.revision + 1);
|
||||
assert_eq!(renamed.revision, before_rename + 1);
|
||||
assert!(
|
||||
db.audit(legacy.id)
|
||||
.await
|
||||
@@ -198,16 +202,19 @@ async fn configuration_sync_creates_adopts_renames_disables_and_reenables_withou
|
||||
);
|
||||
|
||||
// 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"],
|
||||
"pending"
|
||||
);
|
||||
assert_eq!(db.sync_status(payments.id).await.unwrap()["state"], "draft");
|
||||
|
||||
// Safety rails.
|
||||
let empty = config_with(vec![]);
|
||||
@@ -342,6 +349,7 @@ async fn groups_decide_roles_exactly_and_instance_administrators_see_everything(
|
||||
.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)
|
||||
@@ -507,7 +515,9 @@ async fn employee_permissions_belong_to_the_workspace_and_apply_to_everyone_at_o
|
||||
.unwrap()
|
||||
.contains(&Permission::ForceOverrides)
|
||||
);
|
||||
let applied = service::request_replacement(&f.db, bob, team, force(updated.revision))
|
||||
// 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!(
|
||||
@@ -1231,7 +1241,7 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
|
||||
sign_in(
|
||||
db,
|
||||
&config,
|
||||
profile("sd", "dana", Some("DANA@corp.test"), &["alpha-staff"]),
|
||||
profile("sd", "dana", Some("dana@corp.test"), &["alpha-staff"]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
@@ -1239,6 +1249,29 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
|
||||
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,
|
||||
@@ -1254,18 +1287,19 @@ async fn first_sign_in_links_the_email_contact_everywhere_and_never_grants_acces
|
||||
.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.
|
||||
// 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());
|
||||
assert!(
|
||||
!db.workspace(alpha.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.document
|
||||
.people
|
||||
.iter()
|
||||
.any(|p| p.id == frank.id)
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1313,15 +1347,15 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
|
||||
),
|
||||
("horizon", &|d| d.horizon_days = 30),
|
||||
("person name", &|d| {
|
||||
d.people[0].display_name = "Someone else".into()
|
||||
person_mut(d, contact).display_name = "Someone else".into()
|
||||
}),
|
||||
("person email", &|d| {
|
||||
d.people[0].email = Some("other@corp.test".into())
|
||||
person_mut(d, contact).email = Some("other@corp.test".into())
|
||||
}),
|
||||
("person country", &|d| {
|
||||
d.people[0].country = Some("CY".into())
|
||||
person_mut(d, contact).country = Some("CY".into())
|
||||
}),
|
||||
("person removal", &|d| d.people.clear()),
|
||||
("person removal", &|d| d.people.retain(|p| p.id != contact)),
|
||||
] {
|
||||
assert!(
|
||||
matches!(try_edit(change).await, Err(Error::Forbidden)),
|
||||
@@ -1339,24 +1373,33 @@ async fn employees_plan_schedules_but_only_administrators_change_preferences_and
|
||||
country: None,
|
||||
aliases: vec![],
|
||||
});
|
||||
d.people[0].email = Some(" DANA@corp.test ".into());
|
||||
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_eq!(planned.document.people.len(), 2);
|
||||
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;
|
||||
doc.people[0].display_name = "Dana Renamed".into();
|
||||
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.people[0].display_name, "Dana Renamed");
|
||||
assert_eq!(
|
||||
changed.document.person(contact).unwrap().display_name,
|
||||
"Dana Renamed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1437,3 +1480,62 @@ async fn the_instance_event_log_is_searchable_newest_first_and_failed_logins_are
|
||||
);
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user