End a locked account's delegation at its date
ci / fork-checks (pull_request) Successful in 44s
ci / build (pull_request) Successful in 4m59s

A delegation with an end date dropped out of the delegate's token then,
but its folder grants stayed until the daily sweep, so the delegate kept
the account as an ordinary share for up to a day. Each node now sleeps
until the soonest end date, woken early by any lock write and at least
hourly, and re-applies that lock under a cluster-wide claim.

The sweep also had a second-run bug: a delegation past its date gave the
delegate back its earlier share, then dropped the note, so the next sweep
removed that share entirely. The note is now kept while the delegate is
still listed.
This commit is contained in:
2026-09-27 16:26:04 -07:00
parent 224597cab2
commit a36236efff
4 changed files with 199 additions and 3 deletions
+33
View File
@@ -313,6 +313,39 @@ pub async fn test(test: &mut TestServer) {
"AL-4: the rejected message wasn't kept: {kept}"
);
// AL-5: a delegation ends at its `until`, not at the next daily sweep
let soon = store::write::now() + 3;
let response = admin
.lock_set(json!({"reason": "Handover ends shortly",
"update": {owner_id.as_str(): {"delegates": [
{"accountId": delegate.id_string(), "access": "organize",
"until": chrono::DateTime::from_timestamp(soon as i64, 0).unwrap().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)}]}}}))
.await;
assert!(
response["updated"].get(owner_id.as_str()).is_some(),
"AL-5: {response}"
);
let (_, before) = delegate
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
.await;
assert!(
before["list"].as_array().is_some_and(|l| !l.is_empty()),
"AL-5: the delegate lost the account before its end: {before}"
);
tokio::time::sleep(Duration::from_secs(6)).await;
let session = delegate.jmap_session_object().await.0;
assert!(
session["accounts"].get(owner_id.as_str()).is_none(),
"AL-5: the delegation outlived its end in the session: {session}"
);
let (_, after) = delegate
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
.await;
assert!(
after["list"].as_array().is_none_or(|l| l.is_empty()),
"AL-5: the delegate still reaches the folders after its end: {after}"
);
// AL-10: unlocking needs a reason, then restores everything
let response = admin
.lock_set(json!({"destroy": [owner_id]}))