Legal holds, step 4: freezing, release, and the audit log
Placing or widening a hold freezes what's already archived in its scope and range, its old deadline noted; releasing one gives each item no other hold covers that deadline back, or release plus 30 days if later. One pass over the archive does both and changes nothing twice (LH-6, LH-10, LH-11). A held archived item can't be destroyed; restoring still can, and the hold is named only to callers who may see holds (LH-7). Audit records about a held account survive the purge (AU-7). Fixes the daily clean-up of expired archived items (UD-13), which never found any: the registry's unfiltered query reads an all-ids index that archived items aren't in. Items are now walked account by account, kept deleted accounts included. Expired items were still removed whenever their account's archive was read.
This commit is contained in:
@@ -33,7 +33,9 @@ const USING: &[&str] = &[
|
||||
|
||||
impl Account {
|
||||
async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) {
|
||||
arguments["accountId"] = self.id_string().into();
|
||||
if arguments.get("accountId").is_none() {
|
||||
arguments["accountId"] = self.id_string().into();
|
||||
}
|
||||
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
|
||||
let call = response
|
||||
.0
|
||||
@@ -364,6 +366,90 @@ pub async fn test(test: &mut TestServer) {
|
||||
"LH-3: a contact wasn't kept whole: {kept:?}"
|
||||
);
|
||||
|
||||
// LH-6: placing a hold freezes what's already archived; LH-7: frozen
|
||||
// items can't be destroyed; LH-11: releasing one hold of two frees
|
||||
// nothing; LH-10: releasing the last gives a real deadline back
|
||||
admin
|
||||
.registry_update_setting(
|
||||
DataRetention {
|
||||
archive_deleted_items_for: Some(registry::schema::prelude::Duration(
|
||||
std::time::Duration::from_secs(30 * 86_400),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::ArchiveDeletedItemsFor],
|
||||
)
|
||||
.await;
|
||||
let frozen = admin
|
||||
.create_user_account("[email protected]", "frozen-secret-9031", "Frozen", &[], vec![])
|
||||
.await;
|
||||
let frozen_client = frozen.jmap_client().await;
|
||||
let doomed = import(&frozen_client, "Deleted before the hold", None).await;
|
||||
frozen_client.email_destroy(&doomed).await.unwrap();
|
||||
test.wait_for_tasks().await;
|
||||
let archived = |items: Vec<Value>| {
|
||||
items
|
||||
.into_iter()
|
||||
.find(|i| i["subject"] == "Deleted before the hold")
|
||||
.unwrap_or_else(|| panic!("not archived"))
|
||||
};
|
||||
let item = archived(frozen.archived_items().await);
|
||||
assert!(!is_held(&item), "undelete's 30 days first: {item}");
|
||||
let item_id = item["id"].as_str().unwrap().to_string();
|
||||
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "First matter", "create": {
|
||||
"a": {"name": "Matter 7001", "scope": {"accounts": [frozen.id_string()]}},
|
||||
"b": {"name": "Matter 7002", "scope": {"accounts": [frozen.id_string()]}}}}))
|
||||
.await;
|
||||
let first = response["created"]["a"]["id"].as_str().unwrap().to_string();
|
||||
let second = response["created"]["b"]["id"].as_str().unwrap().to_string();
|
||||
assert!(
|
||||
is_held(&archived(frozen.archived_items().await)),
|
||||
"test 6, LH-6: the archived item wasn't frozen"
|
||||
);
|
||||
|
||||
let (_, response) = frozen
|
||||
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notDestroyed"][item_id.as_str()]["type"], "forbidden",
|
||||
"test 6, LH-7: the owner destroyed a held item: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.to_string().contains("Matter 70"),
|
||||
"LH-7: the hold was named to someone who can't see holds: {response}"
|
||||
);
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"x:ArchivedItem/set",
|
||||
json!({"accountId": frozen.id_string(), "destroy": [item_id]}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response.to_string().contains("Matter 7001"),
|
||||
"LH-7: the administrator isn't told which hold: {response}"
|
||||
);
|
||||
|
||||
admin
|
||||
.hold_set(json!({"reason": "First settled", "update": {first.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
assert!(
|
||||
is_held(&archived(frozen.archived_items().await)),
|
||||
"test 9, LH-11: releasing one hold of two freed the item"
|
||||
);
|
||||
admin
|
||||
.hold_set(json!({"reason": "Second settled", "update": {second.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
let item = archived(frozen.archived_items().await);
|
||||
assert!(!is_held(&item), "LH-10: the last release left it held: {item}");
|
||||
let until = item["archivedUntil"].as_str().unwrap_or_default().to_string();
|
||||
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||
assert!(
|
||||
until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"test 8, LH-10: under 30 days of grace after release: {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}}}))
|
||||
|
||||
Reference in New Issue
Block a user