Legal holds, step 5: held accounts can't be destroyed

Destroying a held account removes the login, as offboarding needs, but
keeps its data as a deleted account with no expiry, whether or not
undelete keeps accounts; its addresses stay reserved and its holds name
it from then on. Destroy-now refuses it, and its DestroyAccount task
defers itself while it's held or its time hasn't come. Holds placed or
released later freeze or free kept accounts in the same settle pass,
with 30 days' grace after the last release (LH-8, LH-10).
This commit is contained in:
2026-09-27 19:33:07 -07:00
parent 8d3e99bc00
commit 39707cd2e8
5 changed files with 168 additions and 7 deletions
+50 -1
View File
@@ -309,7 +309,10 @@ pub async fn test(test: &mut TestServer) {
"r": {"name": "Matter 6002", "from": "2020-01-01T00:00:00Z", "to": "2020-12-31T23:59:59Z",
"scope": {"accounts": [ranged.id_string()]}}}}))
.await;
assert!(response["created"]["w"]["id"].is_string(), "LH-1: {response}");
let whole_hold = response["created"]["w"]["id"]
.as_str()
.unwrap_or_else(|| panic!("LH-1: {response}"))
.to_string();
assert!(response["created"]["r"]["id"].is_string(), "LH-1: {response}");
let held_client = held.jmap_client().await;
@@ -450,6 +453,52 @@ pub async fn test(test: &mut TestServer) {
"test 8, LH-10: under 30 days of grace after release: {until}"
);
// Test 8, LH-8: a held account destroyed as a login is kept, data and
// all, with no expiry, although undelete keeps no accounts here
let held_id = held.id_string().to_string();
admin.destroy_account(held).await;
let kept = |list: Value| {
list["list"]
.as_array()
.and_then(|l| l.iter().find(|a| a["id"] == held_id.as_str()).cloned())
};
let (_, list) = admin
.hold_call("inbuxa:DeletedAccount/get", json!({"ids": null}))
.await;
let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-8: not kept: {list}"));
assert!(
entry["keptUntil"].as_str().is_some_and(|u| u.starts_with("9999-")),
"test 8, LH-8: kept with an expiry: {entry}"
);
let (_, response) = admin
.hold_call("inbuxa:DeletedAccount/set", json!({"destroy": [held_id]}))
.await;
assert_eq!(
response["notDestroyed"][held_id.as_str()]["type"], "forbidden",
"test 8, LH-8: destroy-now wasn't refused: {response}"
);
// Its hold names it now, so no domain or tenant move can drop it
assert!(
admin.hold_get(&whole_hold).await["scope"]["accounts"]
.as_array()
.is_some_and(|a| a.iter().any(|id| id == held_id.as_str())),
"LH-8: the hold doesn't name the deleted account"
);
// Release: the data is destroyed 30 days later, not before
admin
.hold_set(json!({"reason": "Matter closed", "update": {whole_hold.as_str(): {"released": true}}}))
.await;
let (_, list) = admin
.hold_call("inbuxa:DeletedAccount/get", json!({"ids": null}))
.await;
let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-10: gone at release: {list}"));
let until = entry["keptUntil"].as_str().unwrap_or_default().to_string();
let grace = chrono::Utc::now() + chrono::Duration::days(29);
assert!(
!until.starts_with("9999-") && until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(),
"test 8, LH-10: after release, not 30 days of grace: {until}"
);
// LH-10: release needs a reason, and a released hold stays, read-only
let response = admin
.hold_set(json!({"update": {hold_id.as_str(): {"released": true}}}))