Files
inbuxa-server/tests/src/system/legal_hold.rs
T
jcoffey-dev 68dd749291
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 4m47s
Export what a legal hold keeps as a ZIP (LH-12)
inbuxa:HoldExport/set takes a hold, optionally some of the accounts it
covers, and a reason; the collection runs in the background and get
says when it's ready. The ZIP has, per account, mail as .eml under its
folders, calendars as .ics, contacts as .vcf, files as stored, and the
archived items the hold keeps under archived/; a manifest.csv gives each
entry's account, kind, folder, date, whether it was archived, size and
SHA-256, and manifest.sha256 hashes the manifest. Accounts the hold
doesn't cover are left out, and items outside its date range are too:
live mail by arrival, events by start, and archived items the same way,
so an export doesn't carry deleted items that only another hold keeps.

The finished file is a blob of whoever started the export, so only they
download it, and it lasts as long as any upload (uploadTtl). Exports
are records under the hold (SUBSPACE_INBUXA H/e): never changed or
destroyed, each with its status, counts, size and checksum. Starting
one needs sysLegalHoldExport, an active hold and a reason, and is
recorded in the audit log like the audit log's own export.

The build is in memory and capped at 2 GB; bigger holds fail with a
message saying so, and are split by picking accounts.

Tested: unit tests for safe ZIP names and the manifest and its hash;
the legal_hold system test, on RocksDB, PostgreSQL and MySQL, exports a
hold end to end (live and archived mail, the manifest's hash, an asked-
for account the hold doesn't cover left out) and checks the refusals
(no reason, a user without the permission, a released hold) and the
audit record; and by hand from the console on a local server. Not
covered by a test: the archived-item date range with two holds of
different ranges over one account.
2026-09-27 21:45:52 -07:00

750 lines
29 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Legal holds, the object itself (audit-hold-lock spec, LH-1, LH-3, LH-13,
//! AU-12): placing, widening and releasing a hold, and who may. What a hold
//! keeps is tested with the undelete hooks.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{
CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant,
UserRoles,
},
};
use serde_json::{Value, json};
use types::id::Id;
const INBOX_ID: u32 = 0;
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:contacts",
"urn:inbuxa:jmap",
];
impl Account {
async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) {
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
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0));
(call[0].as_str().unwrap_or_default().to_string(), call[1].clone())
}
async fn hold_set(&self, arguments: Value) -> Value {
let (name, response) = self.hold_call("inbuxa:LegalHold/set", arguments).await;
assert_eq!(name, "inbuxa:LegalHold/set", "{response}");
response
}
async fn archived_items(&self) -> Vec<Value> {
let (_, response) = self
.hold_call("x:ArchivedItem/get", json!({"ids": null}))
.await;
response["list"].as_array().cloned().unwrap_or_default()
}
async fn hold_get(&self, id: &str) -> Value {
let (name, response) = self
.hold_call("inbuxa:LegalHold/get", json!({"ids": [id]}))
.await;
assert_eq!(name, "inbuxa:LegalHold/get", "{response}");
response["list"][0].clone()
}
}
pub async fn test(test: &mut TestServer) {
println!("Running legal hold tests...");
let admin = test.account("[email protected]");
let custodian = admin
.create_user_account("[email protected]", "custodian-secret-2201", "Custodian", &[], vec![])
.await;
let other = admin
.create_user_account("[email protected]", "other-secret-7310", "Other", &[], vec![])
.await;
let custodian_id = custodian.id_string().to_string();
let other_id = other.id_string().to_string();
// AU-12: no hold without a reason; LH-1: nor without a name or a scope
let response = admin
.hold_set(json!({"create": {"h": {"name": "Matter 4411",
"scope": {"accounts": [custodian_id]}}}}))
.await;
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "AU-12: {response}");
let response = admin
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
"scope": {"accounts": [custodian_id]}}}}))
.await;
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 name: {response}");
let response = admin
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
"name": "Matter 4411", "scope": {}}}}))
.await;
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 scope: {response}");
let response = admin
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
"name": "Matter 4411", "scope": {"accounts": ["zzzzzz"]}}}}))
.await;
assert_eq!(
response["notCreated"]["h"]["type"], "invalidProperties",
"LH-1 unknown account: {response}"
);
let response = admin
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
"name": "Matter 4411",
"from": "2026-06-30T00:00:00Z", "to": "2026-01-01T00:00:00Z",
"scope": {"accounts": [custodian_id]}}}}))
.await;
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-3 backwards: {response}");
// LH-1: placed, with a reference and a range
let response = admin
.hold_set(json!({"create": {"h": {
"name": "Matter 4411", "reference": "4411-A", "reason": "Counsel's letter",
"from": "2026-01-01T00:00:00Z", "to": "2026-06-30T23:59:59Z",
"scope": {"accounts": [custodian_id]}}}}))
.await;
let hold_id = response["created"]["h"]["id"]
.as_str()
.unwrap_or_else(|| panic!("LH-1: not placed: {response}"))
.to_string();
let hold = admin.hold_get(&hold_id).await;
assert_eq!(hold["name"], "Matter 4411", "{hold}");
assert_eq!(hold["reference"], "4411-A", "{hold}");
assert_eq!(hold["scope"]["accounts"], json!([custodian_id]), "{hold}");
assert_eq!(hold["from"], "2026-01-01T00:00:00Z", "{hold}");
assert_eq!(hold["released"], false, "{hold}");
assert!(hold["placedBy"].as_str().is_some_and(|by| by.contains("admin")), "{hold}");
// AU-12: every later change needs a reason too
let response = admin
.hold_set(json!({"update": {hold_id.as_str(): {"name": "Renamed"}}}))
.await;
assert_eq!(
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
"AU-12: {response}"
);
// LH-3: narrowing is refused, widening is allowed
let response = admin
.hold_set(json!({"reason": "Narrow it", "update": {hold_id.as_str(): {
"from": "2026-03-01T00:00:00Z"}}}))
.await;
assert_eq!(
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
"LH-3 narrowed: {response}"
);
let response = admin
.hold_set(json!({"reason": "Counsel widened the matter", "update": {hold_id.as_str(): {
"from": "2025-01-01T00:00:00Z", "to": null}}}))
.await;
assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-3 widened: {response}");
let hold = admin.hold_get(&hold_id).await;
assert_eq!(hold["from"], "2025-01-01T00:00:00Z", "{hold}");
assert_eq!(hold["to"], Value::Null, "LH-3: an open end catches mail to come: {hold}");
// The scope grows, and never shrinks
let response = admin
.hold_set(json!({"reason": "Second custodian", "update": {hold_id.as_str(): {
"scope": {"accounts": [custodian_id, other_id]}}}}))
.await;
assert!(response["updated"].get(hold_id.as_str()).is_some(), "scope grown: {response}");
let response = admin
.hold_set(json!({"reason": "Drop one", "update": {hold_id.as_str(): {
"scope": {"accounts": [other_id]}}}}))
.await;
assert_eq!(
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
"scope shrunk: {response}"
);
// LH-13: a hold is never deleted
let response = admin
.hold_set(json!({"reason": "Delete it", "destroy": [hold_id]}))
.await;
assert_eq!(
response["notDestroyed"][hold_id.as_str()]["type"], "forbidden",
"LH-13: {response}"
);
// LH-13: only server-level administrators see holds, never a plain user
let (name, response) = custodian
.hold_call("inbuxa:LegalHold/get", json!({"ids": null}))
.await;
assert_eq!(name, "error", "LH-13: a user read holds: {response}");
// ... and never a tenant administrator, whatever its role says: a hold
// may concern the tenant's own administrator
let tenant = admin
.registry_create_object(Tenant {
name: "Hold tenant".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(Domain {
name: "tenant-hold.example.org".to_string(),
is_enabled: true,
member_tenant_id: Some(tenant),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let t_admin = admin
.create_user_account(
"[email protected]",
"tenant-admin-secret-6604",
"Tenant admin",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
t_admin.id(),
json!({Property::Roles: UserRoles::Admin}),
)
.await;
let (name, response) = t_admin
.hold_call("inbuxa:LegalHold/get", json!({"ids": null}))
.await;
assert_eq!(name, "error", "LH-13: a tenant administrator read holds: {response}");
let (name, response) = t_admin
.hold_call(
"inbuxa:LegalHold/set",
json!({"reason": "Mine", "create": {"h": {"name": "Tenant matter",
"scope": {"accounts": [t_admin.id_string()]}}}}),
)
.await;
assert_eq!(name, "error", "LH-13: a tenant administrator placed a hold: {response}");
// Test 7, LH-2: a hold on a domain reaches an account created there
// later, and keeps it by name when it moves to another domain
let held_domain = admin
.registry_create_object(Domain {
name: "held.example.net".to_string(),
is_enabled: true,
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let elsewhere = admin
.registry_create_object(Domain {
name: "elsewhere.example.net".to_string(),
is_enabled: true,
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
let response = admin
.hold_set(json!({"reason": "Whole division", "create": {"d": {
"name": "Matter 5120", "scope": {"domains": [held_domain.to_string()]}}}}))
.await;
let domain_hold = response["created"]["d"]["id"]
.as_str()
.unwrap_or_else(|| panic!("LH-1 domain hold: {response}"))
.to_string();
let mover = admin
.create_user_account("[email protected]", "mover-secret-8812", "Mover", &[], vec![])
.await;
assert_eq!(
admin.hold_get(&domain_hold).await["scope"]["accounts"],
json!([]),
"LH-2: covered through the domain, not named yet"
);
admin
.registry_update_object(
ObjectType::Account,
mover.id(),
json!({Property::DomainId: elsewhere.to_string()}),
)
.await;
assert_eq!(
admin.hold_get(&domain_hold).await["scope"]["accounts"],
json!([mover.id_string()]),
"test 7, LH-2: the moved account escaped the hold"
);
// Test 6, LH-4: what a hold keeps, with undelete switched off, so only
// the hold can be keeping anything
admin
.registry_update_setting(
DataRetention {
archive_deleted_items_for: None,
..Default::default()
},
&[Property::ArchiveDeletedItemsFor],
)
.await;
let held = admin
.create_user_account("[email protected]", "held-secret-4419", "Held", &[], vec![])
.await;
let ranged = admin
.create_user_account("[email protected]", "ranged-secret-5530", "Ranged", &[], vec![])
.await;
let response = admin
.hold_set(json!({"reason": "Preserve everything", "create": {
"w": {"name": "Matter 6001", "scope": {"accounts": [held.id_string()]}},
"r": {"name": "Matter 6002", "from": "2020-01-01T00:00:00Z", "to": "2020-12-31T23:59:59Z",
"scope": {"accounts": [ranged.id_string()]}}}}))
.await;
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;
let ranged_client = ranged.jmap_client().await;
let whole = import(&held_client, "Held whole", None).await;
held_client.email_destroy(&whole).await.unwrap();
// 2020-03-15: inside the range; now: outside it
let inside = import(&ranged_client, "Inside the range", Some(1_584_230_400)).await;
let outside = import(&ranged_client, "Outside the range", None).await;
ranged_client.email_destroy(&inside).await.unwrap();
ranged_client.email_destroy(&outside).await.unwrap();
// LH-3: a contact is held whole, whatever the range
let (_, books) = ranged
.hold_call("AddressBook/get", json!({"ids": null}))
.await;
let book = books["list"][0]["id"]
.as_str()
.unwrap_or_else(|| panic!("no address book: {books}"))
.to_string();
{
let (_, created) = ranged
.hold_call(
"ContactCard/set",
json!({"create": {"c": {"addressBookIds": {book: true},
"name": {"full": "Kept Contact"}}}}),
)
.await;
let card = created["created"]["c"]["id"].as_str().unwrap_or_default().to_string();
let (_, destroyed) = ranged
.hold_call("ContactCard/set", json!({"destroy": [card]}))
.await;
assert!(destroyed["destroyed"][0].is_string(), "{destroyed}");
}
test.wait_for_tasks().await;
let is_held = |item: &Value| item["archivedUntil"].as_str().is_some_and(|u| u.starts_with("9999-"));
let kept = held.archived_items().await;
assert!(
kept.iter().any(|i| i["subject"] == "Held whole" && is_held(i)),
"test 6, LH-4: a held account's mail wasn't kept: {kept:?}"
);
let kept = ranged.archived_items().await;
assert!(
kept.iter().any(|i| i["subject"] == "Inside the range" && is_held(i)),
"LH-3: mail inside the range wasn't kept: {kept:?}"
);
assert!(
!kept.iter().any(|i| i["subject"] == "Outside the range"),
"LH-3: mail outside the range was kept, with undelete off: {kept:?}"
);
assert!(
kept.iter().any(|i| i["name"] == "Kept Contact" && is_held(i)),
"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"
);
// LH-9: what the hold keeps, for the console
let (_, response) = admin
.hold_call(
"inbuxa:LegalHold/get",
json!({"ids": [first], "properties": ["accountsCovered", "itemsHeld", "sizeHeld"]}),
)
.await;
let summary = &response["list"][0];
assert_eq!(summary["accountsCovered"], 1, "LH-9: {response}");
assert_eq!(summary["itemsHeld"], 1, "LH-9: {response}");
assert!(summary["sizeHeld"].as_u64().is_some_and(|s| s > 0), "LH-9: {response}");
// LH-14: the holds on one account, for the console's Held badge
let (_, response) = admin
.hold_call(
"inbuxa:LegalHold/get",
json!({"coveringAccount": frozen.id_string(), "properties": ["name"]}),
)
.await;
let mut names = response["list"]
.as_array()
.map(|l| l.iter().filter_map(|h| h["name"].as_str()).collect::<Vec<_>>())
.unwrap_or_default();
names.sort_unstable();
assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}");
// LH-12: collect what the hold keeps, as a ZIP with its manifest
import(&frozen_client, "Still in the inbox", None).await;
let (_, response) = admin
.hold_call(
"inbuxa:HoldExport/set",
json!({"create": {"x": {"holdId": first, "accountIds": [frozen.id_string()]}}}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["type"], "invalidProperties",
"AU-12: an export without a reason: {response}"
);
let (_, response) = admin
.hold_call(
"inbuxa:HoldExport/set",
json!({"reason": "Production to opposing counsel",
"create": {"x": {"holdId": first,
"accountIds": [frozen.id_string(), admin.id_string()]}}}),
)
.await;
let export_id = response["created"]["x"]["id"]
.as_str()
.unwrap_or_else(|| panic!("LH-12: not started: {response}"))
.to_string();
let mut export = Value::Null;
for _ in 0..60 {
let (_, got) = admin
.hold_call("inbuxa:HoldExport/get", json!({"ids": [export_id]}))
.await;
export = got["list"][0].clone();
if export["status"] != "running" {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
assert_eq!(export["status"], "ready", "LH-12: {export}");
let bytes = admin
.jmap_client()
.await
.download(export["blobId"].as_str().unwrap())
.await
.unwrap();
assert_eq!(export["size"].as_u64(), Some(bytes.len() as u64), "{export}");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names = (0..zip.len())
.map(|i| zip.by_index(i).unwrap().name().to_string())
.collect::<Vec<_>>();
assert!(
names.iter().any(|n| n.starts_with("[email protected]/mail/") && n.ends_with(".eml")),
"LH-12: live mail missing: {names:?}"
);
assert!(
names.iter().any(|n| n.starts_with("[email protected]/archived/email/")),
"LH-12: the kept deleted mail is missing: {names:?}"
);
assert!(
names
.iter()
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.")),
"LH-12: an account the hold doesn't cover was exported: {names:?}"
);
let mut manifest = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
let mut hash = String::new();
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
use sha2::Digest;
let expected: String = sha2::Sha256::digest(manifest.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
// LH-13: only sysLegalHoldExport starts one
let (_, response) = frozen
.hold_call(
"inbuxa:HoldExport/set",
json!({"reason": "Mine", "create": {"x": {"holdId": first}}}),
)
.await;
assert!(
response.to_string().contains("forbidden") && response["created"].is_null(),
"LH-13: a user exported a hold: {response}"
);
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 (_, response) = admin
.hold_call(
"inbuxa:HoldExport/set",
json!({"reason": "Too late", "create": {"x": {"holdId": first}}}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["type"], "invalidProperties",
"LH-12: a released hold was exported: {response}"
);
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}"
);
// 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}}}))
.await;
assert_eq!(
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
"AU-12 release: {response}"
);
let response = admin
.hold_set(json!({"reason": "Matter settled", "update": {hold_id.as_str(): {"released": true}}}))
.await;
assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-10: {response}");
let hold = admin.hold_get(&hold_id).await;
assert_eq!(hold["released"], true, "{hold}");
assert_eq!(hold["releaseReason"], "Matter settled", "{hold}");
assert!(hold["releasedAt"].is_string(), "{hold}");
let response = admin
.hold_set(json!({"reason": "Rename", "update": {hold_id.as_str(): {"name": "After"}}}))
.await;
assert_eq!(
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
"LH-1: a released hold changed: {response}"
);
let response = admin
.hold_set(json!({"reason": "Undo", "update": {hold_id.as_str(): {"released": false}}}))
.await;
assert_eq!(
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
"LH-10: a released hold came back: {response}"
);
// AU-12: placing, widening and releasing are recorded with their reasons
let (_, query) = admin
.hold_call(
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:LegalHold"}}),
)
.await;
let ids = query["ids"].clone();
let (_, records) = admin
.hold_call("inbuxa:AuditEvent/get", json!({"ids": ids}))
.await;
let reasons = records["list"]
.as_array()
.unwrap_or_else(|| panic!("AU-12: no records: {records}"))
.iter()
.filter_map(|r| r["reason"].as_str())
.collect::<Vec<_>>();
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
}
// AU-1.9: an export is recorded with its reason
let (_, query) = admin
.hold_call(
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:HoldExport"}}),
)
.await;
let (_, exports) = admin
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
.await;
assert!(
exports["list"]
.as_array()
.is_some_and(|l| l.iter().any(|r| r["reason"] == "Production to opposing counsel")),
"AU-1.9: the export isn't recorded: {exports}"
);
// A release is recorded under the hold's name, from before to after
let list = records["list"].as_array().cloned().unwrap_or_default();
let release = list
.iter()
.find(|r| r["reason"] == "First settled")
.unwrap_or_else(|| panic!("the first release isn't recorded: {list:?}"));
assert_eq!(release["target"]["name"], "Matter 7001", "{release}");
assert!(
release["changes"]
.as_array()
.is_some_and(|c| c.iter().any(|c| c["field"] == "released" && c["before"] == false && c["after"] == true)),
"the release doesn't read before/after: {release}"
);
// Accounts are named by their full address, not the bare local part
let (_, query) = admin
.hold_call("inbuxa:AuditEvent/query", json!({"filter": {"targetKind": "x:Account"}}))
.await;
let (_, accounts) = admin
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
.await;
assert!(
accounts["list"]
.as_array()
.is_some_and(|l| l.iter().any(|r| r["target"]["name"] == "[email protected]")),
"an account isn't named by its address: {accounts}"
);
}
async fn import(
client: &jmap_client::client::Client,
subject: &str,
received_at: Option<i64>,
) -> String {
client
.email_import(
format!("From: [email protected]\r\nSubject: {subject}\r\n\r\nBody.\r\n").into_bytes(),
[Id::from(INBOX_ID).to_string()],
None::<Vec<&str>>,
received_at,
)
.await
.unwrap()
.take_id()
}
/// Runs these tests alone: `cargo test -p tests legal_hold_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn legal_hold_tests() {
let mut test = TestServerBuilder::new("legal_hold_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}