Undelete: deleted files, calendar events, contacts and Sieve scripts are kept and restored where they were (UD-1, UD-8 to UD-11)

Files, events and contacts are noted when deleted for good and archived by
the unindex task when retention is on; Sieve scripts are archived at
deletion. A restore goes back to its folder, calendar or address book if it
still exists, takes a free " (restored)" name, comes back inactive for
scripts, and is refused over quota with the item left archived.
Acceptance tests 6, 8 and 12.
This commit is contained in:
2026-09-18 21:07:27 -07:00
parent 4a631bd0b5
commit 5b963b06c6
16 changed files with 946 additions and 1 deletions
+131
View File
@@ -9,6 +9,7 @@
use crate::utils::{
account::Account,
webdav::DummyWebDavClient,
imap::{ImapConnection, Type},
jmap::JmapUtils,
pop3::{self, Pop3Connection},
@@ -27,10 +28,13 @@ use registry::{
},
types::duration::Duration,
};
use hyper::StatusCode;
use serde_json::{Value, json};
use types::id::Id;
const SECRET: &str = "undelete test user passphrase";
const EVENT: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nBEGIN:VEVENT\r\nUID:lunch-1\r\nDTSTAMP:20260918T100000Z\r\nDTSTART:20260918T120000Z\r\nDTEND:20260918T130000Z\r\nSUMMARY:Lunch\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
const CARD: &str = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:jane-1\r\nFN:Jane Doe\r\nEND:VCARD\r\n";
const DAY: u64 = 86_400;
pub async fn test(test: &mut TestServer) {
@@ -267,6 +271,133 @@ pub async fn test(test: &mut TestServer) {
"test 15"
);
// Acceptance test 6: a file, event, contact and script each come back to
// the right place (UD-1, UD-8)
let dav = DummyWebDavClient::new(
user.id().document_id(),
"[email protected]",
SECRET,
"[email protected]",
);
let home = "undelete%40example.org";
dav.mkcol("MKCOL", &format!("/dav/file/{home}/Folder/"), [], [])
.await
.with_status(StatusCode::CREATED);
let file = format!("/dav/file/{home}/Folder/notes.txt");
let event = format!("/dav/cal/{home}/default/lunch.ics");
let card = format!("/dav/card/{home}/default/jane.vcf");
for (path, body) in [
(&file, "Some notes.".to_string()),
(&event, EVENT.to_string()),
(&card, CARD.to_string()),
] {
dav.request("PUT", path, body)
.await
.with_status(StatusCode::CREATED);
}
let script = client
.sieve_script_create("Filters", b"keep;".to_vec(), true)
.await
.unwrap()
.take_id();
for path in [&file, &event, &card] {
dav.request("DELETE", path, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
client.sieve_script_deactivate().await.unwrap();
client.sieve_script_destroy(&script).await.unwrap();
test.wait_for_tasks().await;
// A new file takes the old name, so the restored one is renamed
dav.request("PUT", &file, "Newer notes.")
.await
.with_status(StatusCode::CREATED);
for kind in ["FileNode", "CalendarEvent", "ContactCard", "SieveScript"] {
let item = user
.archived()
.await
.into_iter()
.find(|item| item["@type"] == kind)
.unwrap_or_else(|| panic!("test 6: no archived {kind}"));
user.request_restore(item.object_id()).await;
}
test.wait_for_tasks().await;
dav.request("GET", &format!("/dav/file/{home}/Folder/notes%20%28restored%29.txt"), "")
.await
.with_status(StatusCode::OK)
.with_body("Some notes.");
dav.request("GET", &event, "")
.await
.with_status(StatusCode::OK);
dav.request("GET", &card, "").await.with_status(StatusCode::OK);
let restored = client
.sieve_script_query(
jmap_client::sieve::query::Filter::name("Filters").into(),
None::<Vec<_>>,
)
.await
.unwrap();
let restored = client
.sieve_script_get(restored.ids().first().expect("test 6: script"), None::<Vec<_>>)
.await
.unwrap()
.unwrap();
assert!(!restored.is_active(), "test 6: a restored script is inactive");
assert!(
user.archived()
.await
.iter()
.all(|item| item["@type"] == "Email"),
"test 6: records removed"
);
// Acceptance test 8: a restore past the account's quota is refused, and
// the item stays archived (UD-10)
let used = test
.server
.get_used_quota_account(user.id().document_id())
.await
.unwrap();
admin
.registry_update_object(
ObjectType::Account,
user.id(),
json!({ Property::Quotas: {"maxDiskQuota": used} }),
)
.await;
let item = user.archived_with_subject("Via POP3").await;
user.request_restore(item.object_id()).await;
test.wait_for_tasks_skip_failures().await;
assert_eq!(user.count_with_subject("Via POP3").await, 0, "test 8");
assert_eq!(
user.archived_with_subject("Via POP3").await["status"],
"archived",
"test 8: still archived"
);
// The refused restore stays in the queue as failed
admin.registry_destroy_all(ObjectType::Task).await;
admin
.registry_update_object(
ObjectType::Account,
user.id(),
json!({ Property::Quotas: {} }),
)
.await;
// Acceptance test 12: past its deadline an item isn't restorable
admin.set_retention(Some(1)).await;
let id = import(&client, "Short-lived", &[INBOX_ID], &[]).await;
client.email_destroy(&id).await.unwrap();
test.wait_for_tasks().await;
tokio::time::sleep(std::time::Duration::from_millis(2100)).await;
assert!(
user.archived()
.await
.iter()
.all(|item| item["subject"] != "Short-lived"),
"test 12"
);
// Clean up
admin.set_retention(None).await;
for item in user.archived().await {