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.
750 lines
29 KiB
Rust
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();
|
|
}
|
|
}
|