Legal holds, step 3: deleted items in a held account are kept
Every way of deleting mail (JMAP, IMAP EXPUNGE, POP3, mailbox removal, Trash emptying) and Sieve scripts, events, contacts and files now asks how the account's deletions are kept: a hold keeps them with no expiry (archivedUntil 9999-12-31), even with undelete off; otherwise undelete's period applies as before (LH-4). A hold's date range decides by the item's own date (LH-3). Mail is noted as held at deletion and settled when it's archived, once its received date is known; outside the range it gets undelete's deadline or isn't kept. Events go by their start, with a day's slack for time zones; recurring events, contacts, files and scripts are held whole. A groupware item's note now stays until its archive succeeds, and a failure retries the task instead of being logged and lost (LH-5).
This commit is contained in:
@@ -14,11 +14,22 @@ use crate::utils::{
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
|
||||
structs::{
|
||||
CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant,
|
||||
UserRoles,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use types::id::Id;
|
||||
|
||||
const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"];
|
||||
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) {
|
||||
@@ -38,6 +49,13 @@ impl Account {
|
||||
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]}))
|
||||
@@ -266,6 +284,86 @@ pub async fn test(test: &mut TestServer) {
|
||||
"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;
|
||||
assert!(response["created"]["w"]["id"].is_string(), "LH-1: {response}");
|
||||
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-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}}}))
|
||||
@@ -319,6 +417,23 @@ pub async fn test(test: &mut TestServer) {
|
||||
}
|
||||
}
|
||||
|
||||
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")]
|
||||
|
||||
Reference in New Issue
Block a user