diff --git a/crates/common/src/storage/blob.rs b/crates/common/src/storage/blob.rs index 68555057..86a6f34d 100644 --- a/crates/common/src/storage/blob.rs +++ b/crates/common/src/storage/blob.rs @@ -50,11 +50,6 @@ impl Server { let count = v >> COUNT_SHIFT; let size = v & SIZE_MASK; - let c = println!( - "count: {}, size: {}, expires in: {}", - count, size, expires_in - ); - (self.core.jmap.upload_tmp_quota_amount == 0 || count <= self.core.jmap.upload_tmp_quota_amount as u64) && (self.core.jmap.upload_tmp_quota_size == 0 diff --git a/crates/registry/src/types/duration.rs b/crates/registry/src/types/duration.rs index d057f3a5..b46743dd 100644 --- a/crates/registry/src/types/duration.rs +++ b/crates/registry/src/types/duration.rs @@ -166,3 +166,9 @@ impl From for Duration { Duration(value) } } + +impl From for Duration { + fn from(value: u64) -> Self { + Duration(std::time::Duration::from_millis(value)) + } +} diff --git a/crates/registry/src/types/float.rs b/crates/registry/src/types/float.rs index c935c856..efa4cdf3 100644 --- a/crates/registry/src/types/float.rs +++ b/crates/registry/src/types/float.rs @@ -25,7 +25,7 @@ impl PartialOrd for Float { impl Ord for Float { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.partial_cmp(other).unwrap_or_else(|| { + self.0.partial_cmp(&other.0).unwrap_or_else(|| { if self.0.is_nan() && other.0.is_nan() { std::cmp::Ordering::Equal } else if self.0.is_nan() { diff --git a/crates/services/src/task_manager/destroy_account.rs b/crates/services/src/task_manager/destroy_account.rs index 2113dfed..a0e1088b 100644 --- a/crates/services/src/task_manager/destroy_account.rs +++ b/crates/services/src/task_manager/destroy_account.rs @@ -140,6 +140,10 @@ async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Res SearchIndex::Contacts, SearchIndex::Calendar, ] { + let c = println!( + "Unindexing search index {:?} for account {}", + index, account_id + ); server .search_store() .unindex(SearchQuery::new(index).with_account_id(account_id)) diff --git a/crates/services/src/task_manager/manager.rs b/crates/services/src/task_manager/manager.rs index 66552c85..d35c175a 100644 --- a/crates/services/src/task_manager/manager.rs +++ b/crates/services/src/task_manager/manager.rs @@ -261,7 +261,7 @@ impl TaskQueueManager for Server { account_id: 0, collection: 0, document_id: 0, - class: ValueClass::TaskQueue(TaskQueueClass::Due { id: 0, due: 0 }), + class: ValueClass::TaskQueue(TaskQueueClass::Due { id: 0, due: 1 }), }; let to_key = ValueKey:: { account_id: u32::MAX, diff --git a/crates/store/src/write/key.rs b/crates/store/src/write/key.rs index e867b47c..fb9a4ccf 100644 --- a/crates/store/src/write/key.rs +++ b/crates/store/src/write/key.rs @@ -278,7 +278,7 @@ impl ValueClass { .write(collection) .write(document_id), ValueClass::TaskQueue(task) => match task { - TaskQueueClass::Task { id } => serializer.write(*id), + TaskQueueClass::Task { id } => serializer.write(0u64).write(*id), TaskQueueClass::Due { id, due } => serializer.write(*due).write(*id), }, ValueClass::Blob(op) => match op { @@ -513,10 +513,7 @@ impl ValueClass { } } }, - ValueClass::TaskQueue(e) => match e { - TaskQueueClass::Task { .. } => U64_LEN + 1, - TaskQueueClass::Due { .. } => (U64_LEN * 2) + 1, - }, + ValueClass::TaskQueue(_) => (U64_LEN * 2) + 1, ValueClass::Queue(q) => match q { QueueClass::Message(_) => U64_LEN, QueueClass::MessageEvent(_) => U64_LEN * 3, diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 52356c25..85f33d5c 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -300,174 +300,6 @@ async fn init_imap_tests(delete_if_exists: bool) -> IMAPTest { } } -pub trait AssertResult: Sized { - fn assert_folders<'x>( - self, - expected: impl IntoIterator)>, - match_all: bool, - ) -> Self; - - fn assert_response_code(self, code: &str) -> Self; - fn assert_contains(self, text: &str) -> Self; - fn assert_count(self, text: &str, occurrences: usize) -> Self; - fn assert_equals(self, text: &str) -> Self; - fn into_response_code(self) -> String; - fn into_highest_modseq(self) -> String; - fn into_uid_validity(self) -> String; - fn into_append_uid(self) -> String; - fn into_copy_uid(self) -> String; - fn into_modseq(self) -> String; -} - -impl AssertResult for Vec { - fn assert_folders<'x>( - self, - expected: impl IntoIterator)>, - match_all: bool, - ) -> Self { - let mut match_count = 0; - 'outer: for (mailbox_name, flags) in expected.into_iter() { - for result in self.iter() { - if result.contains(&format!("\"{}\"", mailbox_name)) { - for flag in flags { - if !flag.is_empty() && !result.contains(flag) { - panic!("Expected mailbox {} to have flag {}", mailbox_name, flag); - } - } - match_count += 1; - continue 'outer; - } - } - panic!("Mailbox {} is not present.", mailbox_name); - } - if match_all && match_count != self.len() - 1 { - panic!( - "Expected {} mailboxes, but got {}: {:?}", - match_count, - self.len() - 1, - self.iter().collect::>() - ); - } - self - } - - fn assert_response_code(self, code: &str) -> Self { - if !self.last().unwrap().contains(&format!("[{}]", code)) { - panic!( - "Response code {:?} not found, got {:?}", - code, - self.last().unwrap() - ); - } - self - } - - fn assert_contains(self, text: &str) -> Self { - for line in &self { - if line.contains(text) { - return self; - } - } - panic!("Expected response to contain {:?}, got {:?}", text, self); - } - - fn assert_count(self, text: &str, occurrences: usize) -> Self { - assert_eq!( - self.iter().filter(|l| l.contains(text)).count(), - occurrences, - "Expected {} occurrences of {:?}, found {} in {:?}.", - occurrences, - text, - self.iter().filter(|l| l.contains(text)).count(), - self - ); - self - } - - fn assert_equals(self, text: &str) -> Self { - for line in &self { - if line == text { - return self; - } - } - panic!("Expected response to be {:?}, got {:?}", text, self); - } - - fn into_response_code(self) -> String { - if let Some((_, code)) = self.last().unwrap().split_once('[') - && let Some((code, _)) = code.split_once(']') - { - return code.to_string(); - } - panic!("No response code found in {:?}", self.last().unwrap()); - } - - fn into_append_uid(self) -> String { - if let Some((_, code)) = self.last().unwrap().split_once("[APPENDUID ") - && let Some((code, _)) = code.split_once(']') - && let Some((_, uid)) = code.split_once(' ') - { - return uid.to_string(); - } - panic!("No APPENDUID found in {:?}", self.last().unwrap()); - } - - fn into_copy_uid(self) -> String { - for line in &self { - if let Some((_, code)) = line.split_once("[COPYUID ") - && let Some((code, _)) = code.split_once(']') - && let Some((_, uid)) = code.rsplit_once(' ') - { - return uid.to_string(); - } - } - panic!("No COPYUID found in {:?}", self); - } - - fn into_highest_modseq(self) -> String { - for line in &self { - if let Some((_, value)) = line.split_once("HIGHESTMODSEQ ") { - if let Some((value, _)) = value.split_once(']') { - return value.to_string(); - } else if let Some((value, _)) = value.split_once(')') { - return value.to_string(); - } else { - panic!("No HIGHESTMODSEQ delimiter found in {:?}", line); - } - } - } - panic!("No HIGHESTMODSEQ entries found in {:?}", self); - } - - fn into_modseq(self) -> String { - for line in &self { - if let Some((_, value)) = line.split_once("MODSEQ (") { - if let Some((value, _)) = value.split_once(')') { - return value.to_string(); - } else { - panic!("No MODSEQ delimiter found in {:?}", line); - } - } - } - panic!("No MODSEQ entries found in {:?}", self); - } - - fn into_uid_validity(self) -> String { - for line in &self { - if let Some((_, value)) = line.split_once("UIDVALIDITY ") { - if let Some((value, _)) = value.split_once(']') { - return value.to_string(); - } else if let Some((value, _)) = value.split_once(')') { - return value.to_string(); - } else { - panic!("No UIDVALIDITY delimiter found in {:?}", line); - } - } - } - panic!("No UIDVALIDITY entries found in {:?}", self); - } -} - pub fn expand_uid_list(list: &str) -> AHashSet { let mut items = AHashSet::new(); for uid in list.split(',') { diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs index 3f240f9a..7b958800 100644 --- a/tests/src/jmap/calendar/acl.rs +++ b/tests/src/jmap/calendar/acl.rs @@ -13,10 +13,10 @@ use jmap_proto::{ use serde_json::json; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Calendar ACL tests..."); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); let john_id = john.id_string().to_string(); let jane_id = jane.id_string().to_string(); diff --git a/tests/src/jmap/calendar/alarm.rs b/tests/src/jmap/calendar/alarm.rs index b11fae93..90250592 100644 --- a/tests/src/jmap/calendar/alarm.rs +++ b/tests/src/jmap/calendar/alarm.rs @@ -17,11 +17,11 @@ use tokio::sync::mpsc; use crate::jmap::{IntoJmapSet, JMAPTest, JmapUtils}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Calendar Alarm tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); let account_id = account.id_string(); - let client = account.client(); + let client = account.jmap_client().await; let client_ws = account.client_owned().await; // Create test calendar diff --git a/tests/src/jmap/calendar/calendars.rs b/tests/src/jmap/calendar/calendars.rs index 9f14c2b6..a1d232fb 100644 --- a/tests/src/jmap/calendar/calendars.rs +++ b/tests/src/jmap/calendar/calendars.rs @@ -8,9 +8,9 @@ use crate::jmap::{ChangeType, JMAPTest, JmapUtils}; use jmap_proto::{object::calendar::CalendarProperty, request::method::MethodObject}; use serde_json::json; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Calendar tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); // Make sure the default calendar exists let response = account diff --git a/tests/src/jmap/calendar/event.rs b/tests/src/jmap/calendar/event.rs index 2d35691b..390e6a52 100644 --- a/tests/src/jmap/calendar/event.rs +++ b/tests/src/jmap/calendar/event.rs @@ -16,9 +16,9 @@ use jmap_proto::request::method::MethodObject; use serde_json::{Value, json}; use types::{collection::SyncCollection, id::Id}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Calendar Event tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); // Create test calendars let response = account @@ -452,7 +452,7 @@ pub async fn test(params: &mut JMAPTest) { })); // Query tests - wait_for_tasks(¶ms.server).await; + test.wait_for_tasks().await; assert_eq!( account .jmap_query( diff --git a/tests/src/jmap/calendar/identity.rs b/tests/src/jmap/calendar/identity.rs index 5cabdd95..a062d65b 100644 --- a/tests/src/jmap/calendar/identity.rs +++ b/tests/src/jmap/calendar/identity.rs @@ -12,9 +12,9 @@ use serde_json::json; use store::write::BatchBuilder; use types::{collection::Collection, field::PrincipalField}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Participant Identity tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); // Obtain all identities let response = account diff --git a/tests/src/jmap/calendar/notification.rs b/tests/src/jmap/calendar/notification.rs index 594db036..0baf0c55 100644 --- a/tests/src/jmap/calendar/notification.rs +++ b/tests/src/jmap/calendar/notification.rs @@ -15,11 +15,11 @@ use serde_json::{Value, json}; use store::write::now; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Calendar Event Notification tests..."); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); - let bill = params.account("bill@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let bill = test.account("bill@example.com"); let john_id = john.id_string().to_string(); let jane_id = jane.id_string().to_string(); @@ -73,7 +73,7 @@ pub async fn test(params: &mut JMAPTest) { let john_event_id = response.created(0).id().to_string(); tokio::time::sleep(std::time::Duration::from_millis(600)).await; - wait_for_tasks(¶ms.server).await; + test.wait_for_tasks().await; // Verify Jane and Bill received the share notification let mut jane_event_id = String::new(); diff --git a/tests/src/jmap/contacts/acl.rs b/tests/src/jmap/contacts/acl.rs index 7151ebb7..fedb90ab 100644 --- a/tests/src/jmap/contacts/acl.rs +++ b/tests/src/jmap/contacts/acl.rs @@ -13,10 +13,10 @@ use jmap_proto::{ use serde_json::json; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Contacts ACL tests..."); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); let john_id = john.id_string().to_string(); let jane_id = jane.id_string().to_string(); diff --git a/tests/src/jmap/contacts/addressbook.rs b/tests/src/jmap/contacts/addressbook.rs index 9152aa09..aca115b6 100644 --- a/tests/src/jmap/contacts/addressbook.rs +++ b/tests/src/jmap/contacts/addressbook.rs @@ -9,9 +9,9 @@ use serde_json::json; use crate::jmap::{ChangeType, JMAPTest, JmapUtils}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running AddressBook tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); // Make sure the default address book exists let response = account diff --git a/tests/src/jmap/contacts/contact.rs b/tests/src/jmap/contacts/contact.rs index 7a1a644c..22ac4abf 100644 --- a/tests/src/jmap/contacts/contact.rs +++ b/tests/src/jmap/contacts/contact.rs @@ -16,9 +16,9 @@ use jmap_proto::request::method::MethodObject; use serde_json::{Value, json}; use types::{collection::SyncCollection, id::Id}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Contact Card tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); // Create test address books let response = account @@ -336,7 +336,7 @@ pub async fn test(params: &mut JMAPTest) { })); // Query tests - wait_for_tasks(¶ms.server).await; + test.wait_for_tasks().await; let email = if !params.server.search_store().is_mysql() { "sarah.johnson@example.com" } else { @@ -496,7 +496,7 @@ END:VCARD"# // Clean up account.destroy_all_addressbooks().await; - test.assert_is_empty().await;; + test.assert_is_empty().await; } fn test_jscontact_1() -> Value { diff --git a/tests/src/jmap/core/blob.rs b/tests/src/jmap/core/blob.rs index 8586b1ae..4be2f69b 100644 --- a/tests/src/jmap/core/blob.rs +++ b/tests/src/jmap/core/blob.rs @@ -9,10 +9,10 @@ use email::mailbox::INBOX_ID; use serde_json::{Value, json}; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running blob tests..."); let server = params.server.clone(); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); store_blob_expire_all(&server.core.storage.data).await; // Blob/set simple test @@ -356,7 +356,7 @@ pub async fn test(params: &mut JMAPTest) { store_blob_expire_all(&server.core.storage.data).await; // Blob/lookup - let client = account.client(); + let client = account.jmap_client().await; let blob_id = client .email_import( concat!( @@ -412,5 +412,5 @@ pub async fn test(params: &mut JMAPTest) { // Remove test data test.destroy_all_mailboxes(account).await; - test.assert_is_empty().await;; + test.assert_is_empty().await; } diff --git a/tests/src/jmap/core/event_source.rs b/tests/src/jmap/core/event_source.rs index 31acf934..8ae8ac03 100644 --- a/tests/src/jmap/core/event_source.rs +++ b/tests/src/jmap/core/event_source.rs @@ -17,12 +17,12 @@ use store::ahash::AHashSet; use tokio::sync::mpsc; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running EventSource tests..."); // Create test account - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mut changes = client .event_source(None::>, false, 1.into(), None) @@ -115,7 +115,7 @@ pub async fn test(params: &mut JMAPTest) { assert_ping(&mut event_rx).await; test.destroy_all_mailboxes(account).await; - test.assert_is_empty().await;; + test.assert_is_empty().await; } async fn assert_state( diff --git a/tests/src/jmap/core/push_subscription.rs b/tests/src/jmap/core/push_subscription.rs index b00cfcdd..ddd03326 100644 --- a/tests/src/jmap/core/push_subscription.rs +++ b/tests/src/jmap/core/push_subscription.rs @@ -46,15 +46,15 @@ private-key = '%{file:{PK}}%' default = true "#; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Push Subscription tests..."); // ECE roundtrip test ece_roundtrip(); // Create test account - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; // Create channels let (event_tx, mut event_rx) = mpsc::channel::(100); diff --git a/tests/src/jmap/core/websocket.rs b/tests/src/jmap/core/websocket.rs index 8bd9dac0..750bd1e5 100644 --- a/tests/src/jmap/core/websocket.rs +++ b/tests/src/jmap/core/websocket.rs @@ -18,12 +18,12 @@ use jmap_client::{ use std::time::Duration; use tokio::sync::mpsc; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running WebSockets tests..."); // Authenticate all accounts - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mut ws_stream = client.connect_ws().await.unwrap(); diff --git a/tests/src/jmap/files/acl.rs b/tests/src/jmap/files/acl.rs index 9db078df..a989c149 100644 --- a/tests/src/jmap/files/acl.rs +++ b/tests/src/jmap/files/acl.rs @@ -11,10 +11,10 @@ use jmap_proto::{ }; use serde_json::json; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running File Storage ACL tests..."); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); let john_id = john.id_string().to_string(); let jane_id = jane.id_string().to_string(); diff --git a/tests/src/jmap/files/node.rs b/tests/src/jmap/files/node.rs index 8084f750..dfd2a82f 100644 --- a/tests/src/jmap/files/node.rs +++ b/tests/src/jmap/files/node.rs @@ -9,9 +9,9 @@ use ahash::AHashSet; use jmap_proto::{object::file_node::FileNodeProperty, request::method::MethodObject}; use serde_json::json; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running File Storage tests..."); - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); // Obtain change id let change_id = account diff --git a/tests/src/jmap/mail/acl.rs b/tests/src/jmap/mail/acl.rs index 93153aee..5d0666e3 100644 --- a/tests/src/jmap/mail/acl.rs +++ b/tests/src/jmap/mail/acl.rs @@ -19,7 +19,7 @@ use std::fmt::Debug; use store::ahash::AHashMap; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running ACL tests..."); let server = params.server.clone(); @@ -27,10 +27,10 @@ pub async fn test(params: &mut JMAPTest) { let inbox_id = Id::new(INBOX_ID as u64).to_string(); let trash_id = Id::new(TRASH_ID as u64).to_string(); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); - let bill = params.account("bill@example.com"); - let sales = params.account("sales@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let bill = test.account("bill@example.com"); + let sales = test.account("sales@example.com"); // Authenticate all accounts let mut john_client = john.client_owned().await; @@ -44,7 +44,7 @@ pub async fn test(params: &mut JMAPTest) { (&mut jane_client, jane.id(), "jane"), (&mut bill_client, bill.id(), "bill"), ( - &mut params.account("admin").client_owned().await, + &mut test.account("admin").client_owned().await, sales.id(), "sales", ), diff --git a/tests/src/jmap/mail/antispam.rs b/tests/src/jmap/mail/antispam.rs index 002b34c3..d4f4511c 100644 --- a/tests/src/jmap/mail/antispam.rs +++ b/tests/src/jmap/mail/antispam.rs @@ -12,10 +12,10 @@ use types::{id::Id, keyword::Keyword}; use crate::{imap::antispam::*, jmap::JMAPTest}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Spam classifier tests..."); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let account_id = account.id().document_id(); // Make sure there are no training samples diff --git a/tests/src/jmap/mail/changes.rs b/tests/src/jmap/mail/changes.rs index ef6d9fad..5db750d8 100644 --- a/tests/src/jmap/mail/changes.rs +++ b/tests/src/jmap/mail/changes.rs @@ -13,12 +13,12 @@ use types::{ id::Id, }; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Changes tests..."); let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mut states = vec![State::Initial]; for (changes, expected_changelog) in [ diff --git a/tests/src/jmap/mail/copy.rs b/tests/src/jmap/mail/copy.rs index e6012588..0cb47199 100644 --- a/tests/src/jmap/mail/copy.rs +++ b/tests/src/jmap/mail/copy.rs @@ -8,9 +8,9 @@ use crate::jmap::{JMAPTest, mail::mailbox::destroy_all_mailboxes_for_account}; use jmap_client::mailbox::Role; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Copy tests..."); - let account = params.account("admin"); + let account = test.account("admin"); let mut client = account.client_owned().await; // Create a mailbox on account 1 diff --git a/tests/src/jmap/mail/crypto.rs b/tests/src/jmap/mail/crypto.rs index 3f9f4a23..cd896267 100644 --- a/tests/src/jmap/mail/crypto.rs +++ b/tests/src/jmap/mail/crypto.rs @@ -12,7 +12,7 @@ use store::{ write::{Archive, Archiver}, }; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Encryption-at-rest tests..."); // Check encryption @@ -20,8 +20,8 @@ pub async fn test(params: &mut JMAPTest) { import_certs_and_encrypt().await; // Create test account - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; // Build API let api = ManagementApi::new(8899, "jdoe@example.com", "12345"); diff --git a/tests/src/jmap/mail/delivery.rs b/tests/src/jmap/mail/delivery.rs deleted file mode 100644 index 64c6ad53..00000000 --- a/tests/src/jmap/mail/delivery.rs +++ /dev/null @@ -1,711 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use crate::{ - directory::internal::TestInternalDirectory, - imap::antispam::{spam_delete_samples, spam_training_samples}, - jmap::JMAPTest, - store::cleanup::store_blob_expire_all, - webdav::DummyWebDavClient, -}; -use common::Server; -use email::{ - cache::{MessageCacheFetch, email::MessageCacheAccess}, - mailbox::{INBOX_ID, JUNK_ID, SENT_ID}, - message::metadata::MessageMetadata, -}; -use groupware::DavResourceName; -use jmap::blob::download::BlobDownload; -use std::{sync::Arc, time::Duration}; -use store::{ - ValueKey, - roaring::RoaringBitmap, - write::{AlignedBytes, Archive}, -}; -use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}, - net::TcpStream, -}; -use types::{ - blob::{BlobClass, BlobId}, - collection::Collection, - field::EmailField, - id::Id, -}; -use utils::chained_bytes::ChainedBytes; - -pub async fn test(params: &mut JMAPTest) { - println!("Running message delivery tests..."); - - // Enable delivered to - let old_core = params.server.core.clone(); - let mut new_core = old_core.as_ref().clone(); - new_core.smtp.session.data.add_delivered_to = true; - params.server.inner.shared_core.store(Arc::new(new_core)); - - // Create a domain name and a test account - let server = params.server.clone(); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); - let bill = params.account("bill@example.com"); - - // Create a mailing list - server - .store() - .create_test_list( - "members@example.com", - "Mailing List", - &[ - "jdoe@example.com", - "jane.smith@example.com", - "bill@example.com", - ], - ) - .await; - - // Delivering to individuals - let mut lmtp = SmtpConnection::connect().await; - params.webhook.clear(); - - lmtp.ingest( - "bill@example.com", - &["jdoe@example.com"], - concat!( - "From: bill@example.com\r\n", - "To: jdoe@example.com\r\n", - "Subject: TPS Report\r\n", - "\r\n", - "I'm going to need those TPS reports ASAP. ", - "So, if you could do that, that'd be great." - ), - ) - .await; - - let john_cache = server - .get_cached_messages(john.id().document_id()) - .await - .unwrap(); - - assert_eq!(john_cache.emails.items.len(), 1); - assert_eq!(john_cache.in_mailbox(INBOX_ID).count(), 1); - assert_eq!(john_cache.in_mailbox(JUNK_ID).count(), 0); - - // Make sure there are no spam training samples - spam_delete_samples(¶ms.server).await; - assert_eq!(spam_training_samples(¶ms.server).await.total_count, 0); - - // Test spam filtering - lmtp.ingest( - "bill@example.com", - &["john.doe@example.com"], - concat!( - "From: bill@example.com\r\n", - "To: john.doe@example.com\r\n", - "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", - "\r\n", - "--- Forwarded Message ---\r\n\r\n ", - "I'm going to need those TPS reports ASAP. ", - "So, if you could do that, that'd be great." - ), - ) - .await; - let john_cache = server - .get_cached_messages(john.id().document_id()) - .await - .unwrap(); - let inbox_ids = john_cache - .in_mailbox(INBOX_ID) - .map(|e| e.document_id) - .collect::(); - let junk_ids = john_cache - .in_mailbox(JUNK_ID) - .map(|e| e.document_id) - .collect::(); - assert_eq!(john_cache.emails.items.len(), 2); - assert_eq!(inbox_ids.len(), 1); - assert_eq!(junk_ids.len(), 1); - assert_message_headers_contains( - &server, - john.id().document_id(), - junk_ids.min().unwrap(), - "X-Spam-Status: Yes", - ) - .await; - assert_eq!(spam_training_samples(¶ms.server).await.total_count, 0); - - // CardDAV spam override - let dav_client = DummyWebDavClient::new(u32::MAX, john.name(), john.secret(), john.emails()[0]); - dav_client - .request( - "PUT", - &format!( - "{}/jdoe%40example.com/default/bill.vcf", - DavResourceName::Card.base_path() - ), - r#"BEGIN:VCARD -VERSION:4.0 -FN:Bill Foobar -EMAIL;TYPE=WORK:dmarc-bill@example.com -UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686f -END:VCARD -"#, - ) - .await - .with_status(hyper::StatusCode::CREATED); - lmtp.ingest( - "dmarc-bill@example.com", - &["john.doe@example.com"], - concat!( - "From: dmarc-bill@example.com\r\n", - "To: john.doe@example.com\r\n", - "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", - "\r\n", - "--- Forwarded Message ---\r\n\r\n ", - "I'm going to need those TPS reports ASAP. ", - "So, if you could do that, that'd be great." - ), - ) - .await; - let john_cache = server - .get_cached_messages(john.id().document_id()) - .await - .unwrap(); - let inbox_ids = john_cache - .in_mailbox(INBOX_ID) - .map(|e| e.document_id) - .collect::(); - let junk_ids = john_cache - .in_mailbox(JUNK_ID) - .map(|e| e.document_id) - .collect::(); - assert_eq!(john_cache.emails.items.len(), 3); - assert_eq!(inbox_ids.len(), 2); - assert_eq!(junk_ids.len(), 1); - dav_client.delete_default_containers().await; - assert_message_headers_contains( - &server, - john.id().document_id(), - inbox_ids.max().unwrap(), - "X-Spam-Status: No, reason=card-exists", - ) - .await; - let samples = spam_training_samples(¶ms.server).await; - assert_eq!(samples.ham_count, 1); - assert_eq!(samples.spam_count, 0); - - // Test trusted reply override - john.client() - .email_import( - concat!( - "From: john.doe@example.com\r\n", - "To: dmarc-bill@example.com\r\n", - "Message-ID: \r\n", - "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", - "\r\n", - "This is a trusted reply." - ) - .as_bytes() - .to_vec(), - vec![Id::from(SENT_ID).to_string()], - None::>, - None, - ) - .await - .unwrap() - .take_id(); - assert_eq!( - server - .get_cached_messages(john.id().document_id()) - .await - .unwrap() - .emails - .items - .len(), - 4 - ); - lmtp.ingest( - "dmarc-bill@example.com", - &["john.doe@example.com"], - concat!( - "From: dmarc-bill@example.com\r\n", - "To: john.doe@example.com\r\n", - "Message-ID: \r\n", - "References: \r\n", - "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", - "\r\n", - "--- Forwarded Message ---\r\n\r\n ", - "I'm going to need those TPS reports ASAP. ", - "So, if you could do that, that'd be great." - ), - ) - .await; - let john_cache = server - .get_cached_messages(john.id().document_id()) - .await - .unwrap(); - let inbox_ids = john_cache - .in_mailbox(INBOX_ID) - .map(|e| e.document_id) - .collect::(); - let junk_ids = john_cache - .in_mailbox(JUNK_ID) - .map(|e| e.document_id) - .collect::(); - assert_eq!(john_cache.emails.items.len(), 5); - assert_eq!(inbox_ids.len(), 3); - assert_eq!(junk_ids.len(), 1); - assert_message_headers_contains( - &server, - john.id().document_id(), - inbox_ids.max().unwrap(), - "X-Spam-Status: No, reason=trusted-reply", - ) - .await; - let samples = spam_training_samples(¶ms.server).await; - assert_eq!(samples.ham_count, 2); - assert_eq!(samples.spam_count, 0); - - // EXPN and VRFY - lmtp.expn("members@example.com", 2) - .await - .assert_contains("jdoe@example.com") - .assert_contains("jane.smith@example.com") - .assert_contains("bill@example.com"); - lmtp.expn("non_existant@example.com", 5).await; - lmtp.expn("jdoe@example.com", 5).await; - lmtp.vrfy("jdoe@example.com", 2).await; - lmtp.vrfy("members@example.com", 5).await; - lmtp.vrfy("non_existant@example.com", 5).await; - - // Delivering to a mailing list - lmtp.ingest( - "bill@example.com", - &["members@example.com"], - concat!( - "From: bill@example.com\r\n", - "To: members@example.com\r\n", - "Subject: WFH policy\r\n", - "\r\n", - "We need the entire staff back in the office, ", - "TPS reports cannot be filed properly from home." - ), - ) - .await; - - tokio::time::sleep(Duration::from_millis(200)).await; - - for (account, num_messages) in [(john, 6), (jane, 1), (bill, 1)] { - assert_eq!( - server - .get_cached_messages(account.id().document_id()) - .await - .unwrap() - .emails - .items - .len(), - num_messages, - "for {}", - account.id_string() - ); - } - - // Removing members from the mailing list and chunked ingest - params - .server - .core - .storage - .data - .remove_from_group("jdoe@example.com", "members@example.com") - .await; - lmtp.ingest_chunked( - "bill@example.com", - &["members@example.com"], - concat!( - "From: bill@example.com\r\n", - "To: members@example.com\r\n", - "Subject: WFH policy (reminder)\r\n", - "\r\n", - "This is a reminder that we need the entire staff back in the office, ", - "TPS reports cannot be filed properly from home." - ), - 10, - ) - .await; - - for (account, num_messages) in [(john, 6), (jane, 2), (bill, 2)] { - assert_eq!( - server - .get_cached_messages(account.id().document_id()) - .await - .unwrap() - .emails - .items - .len(), - num_messages, - "for {}", - account.id_string() - ); - } - - // Deduplication of recipients - lmtp.ingest( - "bill@example.com", - &[ - "members@example.com", - "jdoe@example.com", - "john.doe@example.com", - "jane.smith@example.com", - "bill@example.com", - ], - concat!( - "From: bill@example.com\r\n", - "Bcc: Undisclosed recipients;\r\n", - "Subject: Holidays\r\n", - "\r\n", - "Remember to file your TPS reports before ", - "going on holidays." - ), - ) - .await; - - // Make sure blobs are properly linked - store_blob_expire_all(params.server.store()).await; - - for (account, num_messages) in [(john, 7), (jane, 3), (bill, 3)] { - let account_id = account.id().document_id(); - let cache = server.get_cached_messages(account_id).await.unwrap(); - assert_eq!( - cache.emails.items.len(), - num_messages, - "for {}", - account.id_string() - ); - let access_token = server.get_access_token(account_id).await.unwrap(); - - for document_id in cache.in_mailbox(INBOX_ID).map(|e| e.document_id) { - let metadata = message_metadata(&server, account_id, document_id).await; - let partial_message = server - .store() - .get_blob(metadata.blob_hash.0.as_ref(), 0..usize::MAX) - .await - .unwrap() - .unwrap(); - assert_ne!(metadata.blob_body_offset, 0); - let expected_full_message = String::from_utf8( - ChainedBytes::new(metadata.raw_headers.as_ref()) - .with_last( - partial_message - .get(metadata.blob_body_offset as usize..) - .unwrap_or_default(), - ) - .to_bytes(), - ) - .unwrap(); - assert!( - expected_full_message.contains("Delivered-To:") - && expected_full_message.contains("Subject:"), - "for {account_id}: {expected_full_message}" - ); - let full_message = String::from_utf8( - server - .blob_download( - &BlobId { - hash: metadata.blob_hash, - class: BlobClass::Linked { - account_id, - collection: Collection::Email.into(), - document_id, - }, - section: None, - }, - &access_token, - ) - .await - .unwrap() - .unwrap(), - ) - .unwrap(); - assert_eq!(full_message, expected_full_message, "for {account_id}"); - } - } - - // Remove test data - for account in [john, jane, bill] { - test.destroy_all_mailboxes(account).await; - } - test.assert_is_empty().await;; - - // Restore core - params.server.inner.shared_core.store(old_core); - - // Check webhook events - params.webhook.assert_contains(&[ - "message-ingest.", - "delivery.dsn", - "\"from\": \"bill@example.com\"", - "\"john.doe@example.com\"", - ]); -} - -async fn assert_message_headers_contains( - server: &Server, - account_id: u32, - document_id: u32, - value: &str, -) { - let headers = message_headers(server, account_id, document_id).await; - assert!( - headers.contains(value), - "Expected message headers to contain {:?}, got {:?}", - value, - headers - ); -} - -async fn message_headers(server: &Server, account_id: u32, document_id: u32) -> String { - std::str::from_utf8( - message_metadata(server, account_id, document_id) - .await - .raw_headers - .as_ref(), - ) - .unwrap() - .to_string() -} - -async fn message_metadata(server: &Server, account_id: u32, document_id: u32) -> MessageMetadata { - server - .store() - .get_value::>(ValueKey::property( - account_id, - Collection::Email, - document_id, - EmailField::Metadata, - )) - .await - .unwrap() - .unwrap() - .deserialize::() - .unwrap() -} - -pub struct SmtpConnection { - reader: Lines>>, - writer: WriteHalf, -} - -impl SmtpConnection { - pub async fn ingest_with_code( - &mut self, - from: &str, - recipients: &[&str], - message: &str, - code: u8, - ) -> Vec { - self.mail_from(from, 2).await; - for recipient in recipients { - self.rcpt_to(recipient, 2).await; - } - self.data(3).await; - let result = self.data_bytes(message, recipients.len(), code).await; - tokio::time::sleep(Duration::from_millis(500)).await; - result - } - - pub async fn ingest(&mut self, from: &str, recipients: &[&str], message: &str) { - self.ingest_with_code(from, recipients, message, 2).await; - } - - async fn ingest_chunked( - &mut self, - from: &str, - recipients: &[&str], - message: &str, - chunk_size: usize, - ) { - self.mail_from(from, 2).await; - for recipient in recipients { - self.rcpt_to(recipient, 2).await; - } - for chunk in message.as_bytes().chunks(chunk_size) { - self.bdat(std::str::from_utf8(chunk).unwrap(), 2).await; - } - self.bdat_last("", recipients.len(), 2).await; - tokio::time::sleep(Duration::from_millis(500)).await; - } - - pub async fn connect() -> Self { - SmtpConnection::connect_port(11200).await - } - - pub async fn connect_port(port: u16) -> Self { - let (reader, writer) = tokio::io::split( - TcpStream::connect(&format!("127.0.0.1:{port}")) - .await - .unwrap(), - ); - let mut conn = SmtpConnection { - reader: BufReader::new(reader).lines(), - writer, - }; - conn.read(1, 2).await; - conn.lhlo().await; - conn - } - - pub async fn lhlo(&mut self) -> Vec { - self.send("LHLO localhost").await; - self.read(1, 2).await - } - - pub async fn mail_from(&mut self, sender: &str, code: u8) -> Vec { - self.send(&format!("MAIL FROM:<{}>", sender)).await; - self.read(1, code).await - } - - pub async fn rcpt_to(&mut self, rcpt: &str, code: u8) -> Vec { - self.send(&format!("RCPT TO:<{}>", rcpt)).await; - self.read(1, code).await - } - - pub async fn vrfy(&mut self, rcpt: &str, code: u8) -> Vec { - self.send(&format!("VRFY {}", rcpt)).await; - self.read(1, code).await - } - - pub async fn expn(&mut self, rcpt: &str, code: u8) -> Vec { - self.send(&format!("EXPN {}", rcpt)).await; - self.read(1, code).await - } - - pub async fn data(&mut self, code: u8) -> Vec { - self.send("DATA").await; - self.read(1, code).await - } - - pub async fn data_bytes( - &mut self, - message: &str, - num_responses: usize, - code: u8, - ) -> Vec { - self.send_raw(message).await; - self.send_raw("\r\n.\r\n").await; - self.read(num_responses, code).await - } - - pub async fn bdat(&mut self, chunk: &str, code: u8) -> Vec { - self.send_raw(&format!("BDAT {}\r\n{}", chunk.len(), chunk)) - .await; - self.read(1, code).await - } - - pub async fn bdat_last(&mut self, chunk: &str, num_responses: usize, code: u8) -> Vec { - self.send_raw(&format!("BDAT {} LAST\r\n{}", chunk.len(), chunk)) - .await; - self.read(num_responses, code).await - } - - pub async fn rset(&mut self) -> Vec { - self.send("RSET").await; - self.read(1, 2).await - } - - pub async fn noop(&mut self) -> Vec { - self.send("NOOP").await; - self.read(1, 2).await - } - - pub async fn quit(&mut self) -> Vec { - self.send("QUIT").await; - self.read(1, 2).await - } - - pub async fn read(&mut self, mut num_responses: usize, code: u8) -> Vec { - let mut lines = Vec::new(); - loop { - match tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()).await { - Ok(Ok(Some(line))) => { - let is_done = line.as_bytes()[3] == b' '; - //let c = println!("<- {:?}", line); - lines.push(line); - if is_done { - num_responses -= 1; - if num_responses != 0 { - continue; - } - - if code != u8::MAX { - for line in &lines { - if line.as_bytes()[0] - b'0' != code { - panic!("Expected completion code {}, got {:?}.", code, lines); - } - } - } - return lines; - } - } - Ok(Ok(None)) => { - panic!("Invalid response: {:?}.", lines); - } - Ok(Err(err)) => { - panic!("Connection broken: {} ({:?})", err, lines); - } - Err(_) => panic!("Timeout while waiting for server response: {:?}", lines), - } - } - } - - pub async fn send(&mut self, text: &str) { - //let c = println!("-> {:?}", text); - self.writer.write_all(text.as_bytes()).await.unwrap(); - self.writer.write_all(b"\r\n").await.unwrap(); - self.writer.flush().await.unwrap(); - } - - pub async fn send_raw(&mut self, text: &str) { - //let c = println!("-> {:?}", text); - self.writer.write_all(text.as_bytes()).await.unwrap(); - } -} - -pub trait AssertResult: Sized { - fn assert_contains(self, text: &str) -> Self; - fn assert_count(self, text: &str, occurrences: usize) -> Self; - fn assert_equals(self, text: &str) -> Self; -} - -impl AssertResult for Vec { - fn assert_contains(self, text: &str) -> Self { - for line in &self { - if line.contains(text) { - return self; - } - } - panic!("Expected response to contain {:?}, got {:?}", text, self); - } - - fn assert_count(self, text: &str, occurrences: usize) -> Self { - assert_eq!( - self.iter().filter(|l| l.contains(text)).count(), - occurrences, - "Expected {} occurrences of {:?}, found {}.", - occurrences, - text, - self.iter().filter(|l| l.contains(text)).count() - ); - self - } - - fn assert_equals(self, text: &str) -> Self { - for line in &self { - if line == text { - return self; - } - } - panic!("Expected response to be {:?}, got {:?}", text, self); - } -} diff --git a/tests/src/jmap/mail/get.rs b/tests/src/jmap/mail/get.rs index 1d0e3281..a6d8b6b9 100644 --- a/tests/src/jmap/mail/get.rs +++ b/tests/src/jmap/mail/get.rs @@ -11,7 +11,7 @@ use mail_parser::HeaderName; use std::{fs, path::PathBuf}; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Get tests..."); let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -20,8 +20,8 @@ pub async fn test(params: &mut JMAPTest) { test_dir.push("email_get"); let mailbox_id = Id::from(INBOX_ID).to_string(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; for file_name in fs::read_dir(&test_dir).unwrap() { let mut file_name = file_name.as_ref().unwrap().path(); diff --git a/tests/src/jmap/mail/mailbox.rs b/tests/src/jmap/mail/mailbox.rs index eafeab11..8aabfa56 100644 --- a/tests/src/jmap/mail/mailbox.rs +++ b/tests/src/jmap/mail/mailbox.rs @@ -20,9 +20,9 @@ use std::time::Duration; use store::ahash::AHashMap; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Mailbox tests..."); - let account = params.account("admin"); + let account = test.account("admin"); let mut client = account.client_owned().await; // Create test mailboxes diff --git a/tests/src/jmap/mail/parse.rs b/tests/src/jmap/mail/parse.rs index fbb6ec3d..9d965cb2 100644 --- a/tests/src/jmap/mail/parse.rs +++ b/tests/src/jmap/mail/parse.rs @@ -11,10 +11,10 @@ use jmap_client::{ }; use std::{fs, path::PathBuf}; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Parse tests..."); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); test_dir.push("resources"); diff --git a/tests/src/jmap/mail/query.rs b/tests/src/jmap/mail/query.rs index f10a934e..d7682388 100644 --- a/tests/src/jmap/mail/query.rs +++ b/tests/src/jmap/mail/query.rs @@ -32,11 +32,11 @@ const MAX_THREADS: usize = 100; const MAX_MESSAGES: usize = 1000; const MAX_MESSAGES_PER_THREAD: usize = 100; -pub async fn test(params: &mut JMAPTest, insert: bool) { +pub async fn test(test: &mut TestServer, insert: bool) { println!("Running Email Query tests..."); let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; if insert { // Add some "virtual" mailbox ids so create doesn't fail diff --git a/tests/src/jmap/mail/query_changes.rs b/tests/src/jmap/mail/query_changes.rs index b5447941..48209645 100644 --- a/tests/src/jmap/mail/query_changes.rs +++ b/tests/src/jmap/mail/query_changes.rs @@ -27,11 +27,11 @@ use types::{ id::Id, }; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email QueryChanges tests..."); let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mailbox1_id = client .mailbox_create("JMAP Changes 1", None::, Role::None) diff --git a/tests/src/jmap/mail/search_snippet.rs b/tests/src/jmap/mail/search_snippet.rs index 59cf16f7..eac800e9 100644 --- a/tests/src/jmap/mail/search_snippet.rs +++ b/tests/src/jmap/mail/search_snippet.rs @@ -11,11 +11,11 @@ use std::{fs, path::PathBuf}; use store::ahash::AHashMap; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running SearchSnippet tests..."); let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mailbox_id = Id::from(INBOX_ID).to_string(); let mut email_ids = AHashMap::default(); diff --git a/tests/src/jmap/mail/set.rs b/tests/src/jmap/mail/set.rs index 9020b64c..d54ab089 100644 --- a/tests/src/jmap/mail/set.rs +++ b/tests/src/jmap/mail/set.rs @@ -17,10 +17,10 @@ use jmap_client::{ use std::{fs, path::PathBuf}; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Set tests..."); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mailbox_id = Id::from(INBOX_ID).to_string(); create(client, &mailbox_id).await; diff --git a/tests/src/jmap/mail/sieve_script.rs b/tests/src/jmap/mail/sieve_script.rs index f7b45816..f2044b57 100644 --- a/tests/src/jmap/mail/sieve_script.rs +++ b/tests/src/jmap/mail/sieve_script.rs @@ -26,11 +26,11 @@ use std::{ time::{Duration, Instant}, }; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Sieve tests..."); let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; // Validate scripts client diff --git a/tests/src/jmap/mail/submission.rs b/tests/src/jmap/mail/submission.rs index 195f3ad9..530085d7 100644 --- a/tests/src/jmap/mail/submission.rs +++ b/tests/src/jmap/mail/submission.rs @@ -58,12 +58,12 @@ pub struct MockSMTPSettings { } #[allow(clippy::disallowed_types)] -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running E-mail submissions tests..."); // Start mock SMTP server let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server(); server.ipv4_add( "localhost", diff --git a/tests/src/jmap/mail/thread_get.rs b/tests/src/jmap/mail/thread_get.rs index 6685e39e..350987ec 100644 --- a/tests/src/jmap/mail/thread_get.rs +++ b/tests/src/jmap/mail/thread_get.rs @@ -7,10 +7,10 @@ use crate::jmap::{JMAPTest, wait_for_tasks}; use jmap_client::mailbox::Role; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Email Thread tests..."); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; let mailbox_id = client .mailbox_create("JMAP Get", None::, Role::None) @@ -35,7 +35,7 @@ pub async fn test(params: &mut JMAPTest) { expected_result[num - 1] = email.take_id(); } - wait_for_tasks(¶ms.server).await; + test.wait_for_tasks().await; assert_eq!( client @@ -48,5 +48,5 @@ pub async fn test(params: &mut JMAPTest) { ); test.destroy_all_mailboxes(account).await; - test.assert_is_empty().await;; + test.assert_is_empty().await; } diff --git a/tests/src/jmap/mail/thread_merge.rs b/tests/src/jmap/mail/thread_merge.rs index 085fa826..20185e45 100644 --- a/tests/src/jmap/mail/thread_merge.rs +++ b/tests/src/jmap/mail/thread_merge.rs @@ -23,14 +23,14 @@ use store::{ }; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { test_single_thread(params).await; test_multi_thread(params).await; } -async fn test_single_thread(params: &mut JMAPTest) { +async fn test_single_thread(test: &mut TestServer) { println!("Running Email Merge Threads tests..."); - let account = params.account("admin"); + let account = test.account("admin"); let mut client = account.client_owned().await; let mut all_mailboxes = AHashMap::default(); @@ -141,7 +141,7 @@ async fn test_single_thread(params: &mut JMAPTest) { } } - wait_for_tasks(¶ms.server).await; + test.wait_for_tasks().await; for test_num in 0..=5 { let result = client @@ -206,14 +206,14 @@ async fn test_single_thread(params: &mut JMAPTest) { } } - test.assert_is_empty().await;; + test.assert_is_empty().await; } #[allow(dead_code)] -async fn test_multi_thread(params: &mut JMAPTest) { +async fn test_multi_thread(test: &mut TestServer) { println!("Running Email Merge Threads tests (multi-threaded)..."); let mut handles = vec![]; - let account = params.account("jdoe@example.com"); + let account = test.account("jdoe@example.com"); let account_id = account.id().document_id(); let mailbox_id = INBOX_ID; @@ -278,7 +278,7 @@ async fn test_multi_thread(params: &mut JMAPTest) { ); println!("Deleting all messages..."); test.destroy_all_mailboxes(account).await; - test.assert_is_empty().await;; + test.assert_is_empty().await; } fn build_message(message: usize, in_reply_to: Option, thread_num: usize) -> String { diff --git a/tests/src/jmap/mail/vacation_response.rs b/tests/src/jmap/mail/vacation_response.rs index fe1240ec..e0691b0e 100644 --- a/tests/src/jmap/mail/vacation_response.rs +++ b/tests/src/jmap/mail/vacation_response.rs @@ -19,13 +19,13 @@ use crate::{ use chrono::{TimeDelta, Utc}; use std::time::Instant; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Vacation Response tests..."); // Create test account let server = params.server.clone(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; // Start mock SMTP server let (mut smtp_rx, smtp_settings) = spawn_mock_smtp_server(); diff --git a/tests/src/jmap/principal/availability.rs b/tests/src/jmap/principal/availability.rs index 103f253b..92e3a600 100644 --- a/tests/src/jmap/principal/availability.rs +++ b/tests/src/jmap/principal/availability.rs @@ -10,10 +10,10 @@ use jmap_proto::request::method::MethodObject; use serde_json::json; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Principal Availability tests..."); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); let john_id = john.id_string().to_string(); let jane_id = jane.id_string().to_string(); diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index 4494177c..5f913a17 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -8,12 +8,12 @@ use crate::jmap::{JMAPTest, JmapUtils}; use jmap_proto::{object::principal::PrincipalProperty, request::method::MethodObject}; use serde_json::json; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Principal get/query tests..."); - let john = params.account("jdoe@example.com"); - let jane = params.account("jane.smith@example.com"); - let bill = params.account("bill@example.com"); - let sales = params.account("sales@example.com"); + let john = test.account("jdoe@example.com"); + let jane = test.account("jane.smith@example.com"); + let bill = test.account("bill@example.com"); + let sales = test.account("sales@example.com"); let john_id = john.id_string(); let jane_id = jane.id_string(); diff --git a/tests/src/jmap/server/enterprise.rs b/tests/src/jmap/server/enterprise.rs index 4966a2e1..8b709969 100644 --- a/tests/src/jmap/server/enterprise.rs +++ b/tests/src/jmap/server/enterprise.rs @@ -77,7 +77,7 @@ Subject: undelete test test "; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { // Enable Enterprise println!("Running Enterprise tests..."); let mut core = params.server.inner.shared_core.load_full().as_ref().clone(); @@ -151,7 +151,7 @@ pub async fn test(params: &mut JMAPTest) { destroy_account_data(&server, account_id, true) .await .unwrap(); - test.assert_is_empty().await;; + test.assert_is_empty().await; params.server.inner.shared_core.store( params @@ -241,7 +241,7 @@ async fn alerts(server: &Server) { ); } -async fn tracing(params: &mut JMAPTest) { +async fn tracing(test: &mut TestServer) { // Enable tracing let store = params.server.core.storage.data.clone(); let query = params.server.core.storage.fts.clone(); @@ -291,8 +291,8 @@ async fn tracing(params: &mut JMAPTest) { lmtp.quit().await; tokio::time::sleep(Duration::from_millis(300)).await; - params.server.notify_task_queue(); - wait_for_tasks(¶ms.server).await; + test.server.notify_task_queue(); + test.wait_for_tasks().await; // Purge should not delete anything at this point store @@ -364,7 +364,7 @@ async fn tracing(params: &mut JMAPTest) { ); } -async fn metrics(params: &mut JMAPTest) { +async fn metrics(test: &mut TestServer) { // Make sure there are no span entries in the db let store = params.server.core.storage.data.clone(); assert_eq!( @@ -384,7 +384,7 @@ async fn metrics(params: &mut JMAPTest) { ); } -async fn undelete(params: &mut JMAPTest) { +async fn undelete(test: &mut TestServer) { // Authenticate let mut imap = ImapConnection::connect(b"_x ").await; imap.authenticate("jdoe@example.com", "12345").await; @@ -437,7 +437,7 @@ async fn undelete(params: &mut JMAPTest) { api.get::("/api/store/purge/account/jdoe@example.com") .await .unwrap(); - wait_for_tasks(¶ms.server).await; + test.wait_for_tasks().await; tokio::time::sleep(Duration::from_millis(200)).await; let deleted = api .get::>("/api/store/undelete/jdoe@example.com") diff --git a/tests/src/jmap/server/webhooks.rs b/tests/src/jmap/server/webhooks.rs index e08d8e09..25d50137 100644 --- a/tests/src/jmap/server/webhooks.rs +++ b/tests/src/jmap/server/webhooks.rs @@ -30,7 +30,7 @@ pub struct MockWebhookEndpoint { pub reject: AtomicBool, } -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running Webhook tests..."); // Webhooks endpoint starts disabled by default, make sure there are no events. diff --git a/tests/src/system/authentication.rs b/tests/src/system/authentication.rs index 59c9c2be..7d681ac1 100644 --- a/tests/src/system/authentication.rs +++ b/tests/src/system/authentication.rs @@ -150,8 +150,7 @@ pub async fn test(test: &TestServer) { "very strong password indeed", &[], user_id, - ) - .await; + ); user.registry_query_ids( ObjectType::PublicKey, Vec::<(&str, &str)>::new(), @@ -392,6 +391,8 @@ pub async fn test(test: &TestServer) { ) .await; admin.reload_settings().await; + + test.assert_is_empty().await; } pub async fn validate_password(username: &str, password: &str, is_valid: bool) { diff --git a/tests/src/system/authorization.rs b/tests/src/system/authorization.rs index ec2f44a6..e0eb1da2 100644 --- a/tests/src/system/authorization.rs +++ b/tests/src/system/authorization.rs @@ -76,8 +76,7 @@ pub async fn test(test: &mut TestServer) { "this is a very strong password", &[], user_id, - ) - .await; + ); // Verify user permissions include all permissions from the nested roles user.registry_update_object( @@ -218,10 +217,7 @@ pub async fn test(test: &mut TestServer) { .assert_type(SetErrorType::ObjectIsLinked); // Delete the account and roles in the correct order - admin - .registry_destroy(ObjectType::Account, [user_id]) - .await - .assert_destroyed(&[user_id]); + admin.destroy_account(user).await; for role_id in [l1_role_id, l2_role_id, l3_role_id] { admin .registry_destroy(ObjectType::Role, [role_id]) diff --git a/tests/src/system/delivery.rs b/tests/src/system/delivery.rs new file mode 100644 index 00000000..8dbf2896 --- /dev/null +++ b/tests/src/system/delivery.rs @@ -0,0 +1,565 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::utils::{ + account::Account, imap::AssertResult, server::TestServer, smtp::SmtpConnection, +}; +use common::{Server, auth::BuildAccessToken}; +use email::{ + cache::{MessageCacheFetch, email::MessageCacheAccess}, + mailbox::{INBOX_ID, JUNK_ID, SENT_ID}, + message::metadata::MessageMetadata, +}; +use groupware::DavResourceName; +use jmap::blob::download::BlobDownload; +use registry::{ + schema::{ + prelude::ObjectType, + structs::{EmailAlias, MailingList, SpamTag, SpamTagScore, SpamTrainingSample}, + }, + types::{float::Float, list::List, map::Map}, +}; +use serde_json::json; +use std::time::Duration; +use store::{ + ValueKey, + roaring::RoaringBitmap, + write::{AlignedBytes, Archive}, +}; +use types::{ + blob::{BlobClass, BlobId}, + collection::Collection, + field::EmailField, + id::Id, +}; +use utils::chained_bytes::ChainedBytes; + +pub async fn test(test: &mut TestServer) { + println!("Running message delivery tests..."); + let admin = test.account("admin@example.org"); + + // Prepare tests + admin + .registry_create_object(SpamTag::Score(SpamTagScore { + score: Float::new(1000.0), + tag: "GTUBE_TEST".to_string(), + })) + .await; + + // Create a domain name and a test account + let john = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &["john.doe@example.org"], + ) + .await; + let jane = test + .create_user_account( + "admin@example.org", + "jane.smith@example.org", + "this is a very strong password", + &[], + ) + .await; + let bill = test + .create_user_account( + "admin@example.org", + "bill@example.org", + "this is a very strong password", + &[], + ) + .await; + + // Create a mailing list + let domain_id = admin.find_or_create_domain("example.org").await; + let list_id = admin + .registry_create_object(MailingList { + name: "members".to_string(), + recipients: Map::new(vec![ + "jdoe@example.org".to_string(), + "jane.smith@example.org".to_string(), + "bill@example.org".to_string(), + ]), + aliases: List::from_iter([EmailAlias { + name: "corporate".to_string(), + domain_id, + enabled: true, + ..Default::default() + }]), + domain_id, + ..Default::default() + }) + .await; + + // Delivering to individuals + let mut lmtp = SmtpConnection::connect().await; + + lmtp.ingest( + "bill@example.org", + &["jdoe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", + "Subject: TPS Report\r\n", + "\r\n", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + + assert_eq!(john_cache.emails.items.len(), 1); + assert_eq!(john_cache.in_mailbox(INBOX_ID).count(), 1); + assert_eq!(john_cache.in_mailbox(JUNK_ID).count(), 0); + + // Make sure there are no spam training samples + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; + assert!( + admin + .registry_query( + ObjectType::SpamTrainingSample, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + .ids() + .next() + .is_none() + ); + + // Test spam filtering + lmtp.ingest( + "bill@example.org", + &["john.doe@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: john.doe@example.org\r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "--- Forwarded Message ---\r\n\r\n ", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + let inbox_ids = john_cache + .in_mailbox(INBOX_ID) + .map(|e| e.document_id) + .collect::(); + let junk_ids = john_cache + .in_mailbox(JUNK_ID) + .map(|e| e.document_id) + .collect::(); + assert_eq!(john_cache.emails.items.len(), 2); + assert_eq!(inbox_ids.len(), 1); + assert_eq!(junk_ids.len(), 1); + assert_message_headers_contains( + &test.server, + john.id().document_id(), + junk_ids.min().unwrap(), + "X-Spam-Status: Yes", + ) + .await; + assert_eq!(john.spam_training_samples().await, vec![]); + + // CardDAV spam override + let dav_client = john.webdav_client(); + dav_client + .request( + "PUT", + &format!( + "{}/jdoe%40example.org/default/bill.vcf", + DavResourceName::Card.base_path() + ), + r#"BEGIN:VCARD +VERSION:4.0 +FN:Bill Foobar +EMAIL;TYPE=WORK:dmarc-bill@example.org +UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686f +END:VCARD +"#, + ) + .await + .with_status(hyper::StatusCode::CREATED); + lmtp.ingest( + "dmarc-bill@example.org", + &["john.doe@example.org"], + concat!( + "From: dmarc-bill@example.org\r\n", + "To: john.doe@example.org\r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "--- Forwarded Message ---\r\n\r\n ", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + let inbox_ids = john_cache + .in_mailbox(INBOX_ID) + .map(|e| e.document_id) + .collect::(); + let junk_ids = john_cache + .in_mailbox(JUNK_ID) + .map(|e| e.document_id) + .collect::(); + assert_eq!(john_cache.emails.items.len(), 3); + assert_eq!(inbox_ids.len(), 2); + assert_eq!(junk_ids.len(), 1); + dav_client.delete_default_containers().await; + assert_message_headers_contains( + &test.server, + john.id().document_id(), + inbox_ids.max().unwrap(), + "X-Spam-Status: No, reason=card-exists", + ) + .await; + let samples = john.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 1); + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 0); + + // Test trusted reply override + john.jmap_client() + .await + .email_import( + concat!( + "From: john.doe@example.org\r\n", + "To: dmarc-bill@example.org\r\n", + "Message-ID: \r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "This is a trusted reply." + ) + .as_bytes() + .to_vec(), + vec![Id::from(SENT_ID).to_string()], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + assert_eq!( + test.server + .get_cached_messages(john.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + 4 + ); + lmtp.ingest( + "dmarc-bill@example.org", + &["john.doe@example.org"], + concat!( + "From: dmarc-bill@example.org\r\n", + "To: john.doe@example.org\r\n", + "Message-ID: \r\n", + "References: \r\n", + "Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n", + "\r\n", + "--- Forwarded Message ---\r\n\r\n ", + "I'm going to need those TPS reports ASAP. ", + "So, if you could do that, that'd be great." + ), + ) + .await; + let john_cache = test + .server + .get_cached_messages(john.id().document_id()) + .await + .unwrap(); + let inbox_ids = john_cache + .in_mailbox(INBOX_ID) + .map(|e| e.document_id) + .collect::(); + let junk_ids = john_cache + .in_mailbox(JUNK_ID) + .map(|e| e.document_id) + .collect::(); + assert_eq!(john_cache.emails.items.len(), 5); + assert_eq!(inbox_ids.len(), 3); + assert_eq!(junk_ids.len(), 1); + assert_message_headers_contains( + &test.server, + john.id().document_id(), + inbox_ids.max().unwrap(), + "X-Spam-Status: No, reason=trusted-reply", + ) + .await; + let samples = john.spam_training_samples().await; + assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 2); + assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 0); + + // EXPN and VRFY + lmtp.expn("members@example.org", 2) + .await + .assert_contains("jdoe@example.org") + .assert_contains("jane.smith@example.org") + .assert_contains("bill@example.org"); + lmtp.expn("non_existant@example.org", 5).await; + lmtp.expn("jdoe@example.org", 5).await; + lmtp.vrfy("jdoe@example.org", 2).await; + lmtp.vrfy("members@example.org", 5).await; + lmtp.vrfy("non_existant@example.org", 5).await; + + // Delivering to a mailing list + lmtp.ingest( + "bill@example.org", + &["members@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: members@example.org\r\n", + "Subject: WFH policy\r\n", + "\r\n", + "We need the entire staff back in the office, ", + "TPS reports cannot be filed properly from home." + ), + ) + .await; + + tokio::time::sleep(Duration::from_millis(200)).await; + + for (account, num_messages) in [(&john, 6), (&jane, 1), (&bill, 1)] { + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + num_messages, + "for {}", + account.id_string() + ); + } + + let todos = "todo"; + /* + - MaskedEmail (receiving, expiring, not accessing other users' masked addresses) + - SpamSamples, can't access from other accounts but admin can using filter + - Catchall? Subaddressing? + - Review other code points for more testing ideas + */ + + // Removing members from the mailing list and chunked ingest + admin + .registry_update_object( + ObjectType::MailingList, + list_id, + json!({ + "recipients/jdoe@example.org": false + }), + ) + .await; + lmtp.ingest_chunked( + "bill@example.org", + &["members@example.org"], + concat!( + "From: bill@example.org\r\n", + "To: members@example.org\r\n", + "Subject: WFH policy (reminder)\r\n", + "\r\n", + "This is a reminder that we need the entire staff back in the office, ", + "TPS reports cannot be filed properly from home." + ), + 10, + ) + .await; + + for (account, num_messages) in [(&john, 6), (&jane, 2), (&bill, 2)] { + assert_eq!( + test.server + .get_cached_messages(account.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + num_messages, + "for {}", + account.id_string() + ); + } + + // Deduplication of recipients + lmtp.ingest( + "bill@example.org", + &[ + "members@example.org", + "jdoe@example.org", + "john.doe@example.org", + "jane.smith@example.org", + "bill@example.org", + ], + concat!( + "From: bill@example.org\r\n", + "Bcc: Undisclosed recipients;\r\n", + "Subject: Holidays\r\n", + "\r\n", + "Remember to file your TPS reports before ", + "going on holidays." + ), + ) + .await; + + // Make sure blobs are properly linked + test.blob_expire_all().await; + + for (account, num_messages) in [(&john, 7), (&jane, 3), (&bill, 3)] { + let account_id = account.id().document_id(); + let cache = test.server.get_cached_messages(account_id).await.unwrap(); + assert_eq!( + cache.emails.items.len(), + num_messages, + "for {}", + account.id_string() + ); + let access_token = test.server.access_token(account_id).await.unwrap().build(); + + for document_id in cache.in_mailbox(INBOX_ID).map(|e| e.document_id) { + let metadata = message_metadata(&test.server, account_id, document_id).await; + let partial_message = test + .server + .store() + .get_blob(metadata.blob_hash.0.as_ref(), 0..usize::MAX) + .await + .unwrap() + .unwrap(); + assert_ne!(metadata.blob_body_offset, 0); + let expected_full_message = String::from_utf8( + ChainedBytes::new(metadata.raw_headers.as_ref()) + .with_last( + partial_message + .get(metadata.blob_body_offset as usize..) + .unwrap_or_default(), + ) + .to_bytes(), + ) + .unwrap(); + assert!( + expected_full_message.contains("Delivered-To:") + && expected_full_message.contains("Subject:"), + "for {account_id}: {expected_full_message}" + ); + let full_message = String::from_utf8( + test.server + .blob_download( + &BlobId { + hash: metadata.blob_hash, + class: BlobClass::Linked { + account_id, + collection: Collection::Email.into(), + document_id, + }, + section: None, + }, + &access_token, + ) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(full_message, expected_full_message, "for {account_id}"); + } + } + + // Remove test data + for account in [&john, &jane, &bill] { + test.destroy_all_mailboxes(account).await; + } + test.assert_is_empty().await; + + for account in [john, jane, bill] { + admin.destroy_account(account).await; + } +} + +impl Account { + pub async fn spam_training_sample_ids(&self) -> Vec { + self.registry_query_ids( + ObjectType::SpamTrainingSample, + Vec::<(&str, &str)>::new(), + Vec::<&str>::new(), + ) + .await + } + + pub async fn spam_training_samples(&self) -> Vec<(Id, SpamTrainingSample)> { + let ids = self.spam_training_sample_ids().await; + let mut results = Vec::with_capacity(ids.len()); + for id in ids { + let sample = self.registry_get::(id).await; + results.push((id, sample)); + } + results + } +} + +async fn assert_message_headers_contains( + server: &Server, + account_id: u32, + document_id: u32, + value: &str, +) { + let headers = message_headers(server, account_id, document_id).await; + assert!( + headers.contains(value), + "Expected message headers to contain {:?}, got {:?}", + value, + headers + ); +} + +async fn message_headers(server: &Server, account_id: u32, document_id: u32) -> String { + std::str::from_utf8( + message_metadata(server, account_id, document_id) + .await + .raw_headers + .as_ref(), + ) + .unwrap() + .to_string() +} + +async fn message_metadata(server: &Server, account_id: u32, document_id: u32) -> MessageMetadata { + server + .store() + .get_value::>(ValueKey::property( + account_id, + Collection::Email, + document_id, + EmailField::Metadata, + )) + .await + .unwrap() + .unwrap() + .deserialize::() + .unwrap() +} diff --git a/tests/src/system/directory.rs b/tests/src/system/directory.rs index a1a695e5..f15ad963 100644 --- a/tests/src/system/directory.rs +++ b/tests/src/system/directory.rs @@ -400,4 +400,6 @@ pub async fn test(test: &TestServer) { .is_none() ); assert!(test.server.domain("example.com").await.unwrap().is_none()); + + test.assert_is_empty().await; } diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 4bb5b29a..a9d9e019 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -6,13 +6,16 @@ pub mod authentication; pub mod authorization; +pub mod delivery; pub mod directory; pub mod oidc; +pub mod purge; pub mod quota; pub mod security; pub mod tenant; use crate::utils::server::TestServerBuilder; +use registry::schema::structs::{Imap, SpamClassifier}; #[tokio::test(flavor = "multi_thread")] pub async fn system_tests() { @@ -20,11 +23,21 @@ pub async fn system_tests() { .await .with_default_listeners() .await + .with_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await + .with_object(SpamClassifier { + hold_samples_for: 1u64.into(), + ..Default::default() + }) + .await .build() .await; // Create admin account - let admin_id = test + let admin = test .create_user_account( "admin", "admin@example.org", @@ -33,8 +46,11 @@ pub async fn system_tests() { ) .await; test.account("admin") - .assign_roles_to_account(admin_id, &["user", "system"]) + .assign_roles_to_account(admin.id(), &["user", "system"]) .await; + test.insert_account(admin); + + let todo = "test permissions on account filtered objects"; //directory::test(&test).await; //authentication::test(&test).await; @@ -42,5 +58,7 @@ pub async fn system_tests() { //authorization::test(&mut test).await; //tenant::test(&mut test).await; //security::test(&mut test).await; - quota::test(&mut test).await; + //quota::test(&mut test).await; + //purge::test(&mut test).await; + delivery::test(&mut test).await; } diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index cf08e198..eced61a4 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -29,16 +29,10 @@ use jmap_client::{ client::{Client, Credentials}, mailbox::query::Filter, }; -use registry::{ - schema::{ - enums::JwtSignatureAlgorithm, - prelude::{ObjectType, Property}, - structs::{ - Account, Credential, OidcProvider, PasswordCredential, SecretText, SecretTextValue, - UserAccount, - }, - }, - types::list::List, +use registry::schema::{ + enums::JwtSignatureAlgorithm, + prelude::{ObjectType, Property}, + structs::{OidcProvider, SecretText, SecretTextValue}, }; use serde::{Serialize, de::DeserializeOwned}; use std::time::{Duration, Instant}; @@ -48,7 +42,6 @@ pub async fn test(test: &mut TestServer) { println!("Running OIDC tests..."); let admin = test.account("admin@example.org"); - let domain_id = admin.find_or_create_domain("example.org").await; // Set test parameters let settings = OidcProvider { @@ -86,17 +79,15 @@ pub async fn test(test: &mut TestServer) { admin.reload_settings().await; // Create test account - let user_id = admin - .registry_create_object(Account::User(UserAccount { - name: "user".to_string(), - domain_id, - credentials: List::from_iter([Credential::Password(PasswordCredential { - secret: "this is a very strong password".to_string(), - ..Default::default() - })]), - ..Default::default() - })) + let user = test + .create_user_account( + "admin@example.org", + "user@example.org", + "this is a very strong password", + &[], + ) .await; + let user_id = user.id(); // Build API let http = HttpRequest::new(); @@ -436,14 +427,8 @@ pub async fn test(test: &mut TestServer) { ); // Clean up - assert_eq!( - admin - .registry_destroy(ObjectType::Account, [user_id]) - .await - .destroyed_ids() - .collect::>(), - vec![user_id] - ); + admin.registry_destroy_all(ObjectType::OAuthClient).await; + admin.destroy_account(user).await; test.assert_is_empty().await; } diff --git a/tests/src/jmap/server/purge.rs b/tests/src/system/purge.rs similarity index 70% rename from tests/src/jmap/server/purge.rs rename to tests/src/system/purge.rs index 6915b7dd..06da060c 100644 --- a/tests/src/jmap/server/purge.rs +++ b/tests/src/system/purge.rs @@ -4,34 +4,60 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{ +use crate::utils::{ imap::{AssertResult, ImapConnection, Type}, - jmap::{JMAPTest, wait_for_tasks}, + server::TestServer, }; use ahash::AHashSet; use common::Server; use email::{ cache::{MessageCacheFetch, email::MessageCacheAccess}, mailbox::{INBOX_ID, JUNK_ID, TRASH_ID}, - message::delete::EmailDeletion, }; use imap_proto::ResponseType; +use registry::schema::{ + enums::{TaskAccountMaintenanceType, TaskStoreMaintenanceType}, + prelude::Property, + structs::{DataRetention, Task, TaskAccountMaintenance, TaskStatus, TaskStoreMaintenance}, +}; use store::{IterateParams, LogKey, U32_LEN, U64_LEN, write::key::DeserializeBigEndian}; use types::id::Id; -pub async fn test(params: &mut JMAPTest) { +pub async fn test(test: &mut TestServer) { println!("Running purge tests..."); - let server = params.server.clone(); let inbox_id = Id::from(INBOX_ID).to_string(); let trash_id = Id::from(TRASH_ID).to_string(); let junk_id = Id::from(JUNK_ID).to_string(); - let account = params.account("jdoe@example.com"); - let client = account.client(); + let admin = test.account("admin@example.org"); + + // Set test settings + admin + .registry_update_setting( + DataRetention { + max_changes_history: Some(1), + expunge_trash_after: Some(1000u64.into()), + ..Default::default() + }, + &[Property::MaxChangesHistory, Property::ExpungeTrashAfter], + ) + .await; + admin.reload_settings().await; + + // Create test account + let account = test + .create_user_account( + "admin@example.org", + "jdoe@example.org", + "this is a very strong password", + &[], + ) + .await; + let client = account.jmap_client().await; let mut imap = ImapConnection::connect(b"_x ").await; imap.assert_read(Type::Untagged, ResponseType::Ok).await; - imap.send("LOGIN \"jdoe@example.com\" \"12345\"").await; - imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.authenticate("jdoe@example.org", "this is a very strong password") + .await; imap.send("STATUS INBOX (UIDNEXT MESSAGES UNSEEN)").await; imap.assert_read(Type::Tagged, ResponseType::Ok) .await @@ -50,8 +76,8 @@ pub async fn test(params: &mut JMAPTest) { .email_import( format!( concat!( - "From: bill@example.com\r\n", - "To: jdoe@example.com\r\n", + "From: bill@example.org\r\n", + "To: jdoe@example.org\r\n", "Subject: TPS Report #{} {}\r\n", "\r\n", "I'm going to need those TPS reports ASAP. ", @@ -71,7 +97,7 @@ pub async fn test(params: &mut JMAPTest) { } if pass == 1 { - let (changes_, is_truncated) = get_changes(&server).await; + let (changes_, is_truncated) = get_changes(&test.server).await; assert!(!is_truncated); changes = changes_; tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -91,7 +117,7 @@ pub async fn test(params: &mut JMAPTest) { // Make sure both messages and changes are present assert_eq!( - server + test.server .get_cached_messages(account.id().document_id()) .await .unwrap() @@ -102,15 +128,23 @@ pub async fn test(params: &mut JMAPTest) { ); // Purge junk/trash messages and old changes - server.purge_account(account.id().document_id()).await; - let cache = server + admin + .registry_create_object(Task::AccountMaintenance(TaskAccountMaintenance { + account_id: account.id(), + maintenance_type: TaskAccountMaintenanceType::Purge, + status: TaskStatus::now(), + })) + .await; + test.wait_for_tasks().await; + let cache = test + .server .get_cached_messages(account.id().document_id()) .await .unwrap(); // Only 4 messages should remain assert_eq!( - server + test.server .get_cached_messages(account.id().document_id()) .await .unwrap() @@ -133,7 +167,7 @@ pub async fn test(params: &mut JMAPTest) { .assert_contains("\"Junk Mail\" (MESSAGES 1)"); // Compare changes - let (new_changes, is_truncated) = get_changes(&server).await; + let (new_changes, is_truncated) = get_changes(&test.server).await; assert!(!changes.is_empty()); assert!(!new_changes.is_empty()); assert!(is_truncated); @@ -146,17 +180,19 @@ pub async fn test(params: &mut JMAPTest) { ); } + // Delete expired training samples + admin + .registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { + maintenance_type: TaskStoreMaintenanceType::PurgeBlob, + shard_index: None, + status: TaskStatus::now(), + })) + .await; + // Delete account - test.wait_for_tasks().await; - server - .store() - .delete_principal(QueryBy::Id(account.id().document_id())) - .await - .unwrap(); - destroy_account_data(&server, account.id().document_id(), true) - .await - .unwrap(); - test.assert_is_empty().await;; + admin.destroy_account(account).await; + test.wait_for_tasks().await; + test.assert_is_empty().await; } async fn get_changes(server: &Server) -> (AHashSet<(u64, u8)>, bool) { diff --git a/tests/src/system/quota.rs b/tests/src/system/quota.rs index 32e6d86f..0ace0aab 100644 --- a/tests/src/system/quota.rs +++ b/tests/src/system/quota.rs @@ -5,7 +5,6 @@ */ use crate::utils::{account::Account, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection}; -use common::config::smtp::queue::QueueName; use email::{cache::MessageCacheFetch, mailbox::INBOX_ID}; use jmap::blob::upload::DISABLE_UPLOAD_QUOTA; use jmap_client::{ @@ -24,7 +23,6 @@ use registry::{ types::{EnumImpl, list::List, map::Map}, }; use serde_json::json; -use smtp::queue::spool::SmtpSpool; use types::id::Id; use utils::map::vec_map::VecMap; @@ -100,22 +98,20 @@ pub async fn test(test: &mut TestServer) { "this is a very strong password1", &[], account_id, - ) - .await; + ); let other_account = Account::new( "user2@example.org", "this is a very strong password2", &[], other_account_id, - ) - .await; + ); // Delete temporary blobs from previous tests test.blob_expire_all().await; // Test temporary blob quota (3 files) DISABLE_UPLOAD_QUOTA.store(false, std::sync::atomic::Ordering::Relaxed); - let client = account.client(); + let client = account.jmap_client().await; for i in 0..3 { assert_eq!( client @@ -322,7 +318,7 @@ pub async fn test(test: &mut TestServer) { ); // Test Email/copy quota - let other_client = other_account.client(); + let other_client = other_account.jmap_client().await; let mut other_message_ids = Vec::new(); let mut message_ids = Vec::new(); for i in 0..3 { @@ -423,15 +419,10 @@ pub async fn test(test: &mut TestServer) { // Remove test data test.destroy_all_mailboxes(&account).await; test.destroy_all_mailboxes(&other_account).await; - - for event in test.all_queued_messages().await.messages { - test.server - .read_message(event.queue_id, QueueName::default()) - .await - .unwrap() - .remove(&test.server, event.due.into()) - .await; - } + admin.registry_destroy_all(ObjectType::QueuedMessage).await; + admin + .registry_destroy_all(ObjectType::SpamTrainingSample) + .await; test.assert_is_empty().await; admin diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs index 704a7b25..25937939 100644 --- a/tests/src/system/security.rs +++ b/tests/src/system/security.rs @@ -22,11 +22,9 @@ use registry::{ schema::{ enums::BlockReason, prelude::{ObjectType, Property}, - structs::{ - self, Action, BlockedIp, Credential, Http, Jmap, PasswordCredential, UserAccount, - }, + structs::{Action, BlockedIp, Http, Jmap}, }, - types::{ipmask::IpAddrOrMask, list::List}, + types::ipmask::IpAddrOrMask, }; use serde_json::json; use std::{net::Ipv4Addr, sync::Arc, time::Duration}; @@ -37,7 +35,6 @@ pub async fn test(test: &mut TestServer) { println!("Running Security tests..."); let admin = test.account("admin@example.org"); - let domain_id = admin.find_or_create_domain("example.org").await; // Set security settings admin @@ -66,18 +63,16 @@ pub async fn test(test: &mut TestServer) { .await; admin.reload_settings().await; - // Create a user with the nested role - let user_id = admin - .registry_create_object(structs::Account::User(UserAccount { - name: "user".to_string(), - domain_id, - credentials: List::from_iter([Credential::Password(PasswordCredential { - secret: "this is a very strong password".to_string(), - ..Default::default() - })]), - ..Default::default() - })) + // Create a test user + let user = test + .create_user_account( + "admin@example.org", + "user@example.org", + "this is a very strong password", + &[], + ) .await; + let user_id = user.id(); // Incorrect passwords should be rejected with a 401 error assert!(matches!( @@ -311,11 +306,20 @@ pub async fn test(test: &mut TestServer) { client.upload(None, b"sleep".to_vec(), None).await, Err(jmap_client::Error::Problem(err)) if err.status() == Some(400))); - // Destroy account + // Disable X-Forwarded-For processing admin - .registry_destroy(ObjectType::Account, [user_id]) - .await - .assert_destroyed(&[user_id]); + .registry_update_setting( + Http { + use_x_forwarded: false, + ..Default::default() + }, + &[Property::UseXForwarded], + ) + .await; + admin.reload_settings().await; + + // Destroy account + admin.destroy_account(user).await; test.assert_is_empty().await; } diff --git a/tests/src/system/tenant.rs b/tests/src/system/tenant.rs index 3804c0d0..77b3f522 100644 --- a/tests/src/system/tenant.rs +++ b/tests/src/system/tenant.rs @@ -1,7 +1,11 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: LicenseRef-SEL + * + * This file is subject to the Stalwart Enterprise License Agreement (SEL) and + * is NOT open source software. + * */ use crate::utils::{jmap::JmapUtils, server::TestServer}; @@ -86,15 +90,13 @@ pub async fn test(test: &mut TestServer) { "tenant x secret", &[], tenant_x_ids[&ObjectType::TaskManager], - ) - .await; + ); let admin_y = crate::utils::account::Account::new( "admin@tenanty.org", "tenant y secret", &[], tenant_y_ids[&ObjectType::TaskManager], - ) - .await; + ); assert_eq!( admin_x .registry_create([Tenant { @@ -565,6 +567,7 @@ pub async fn test(test: &mut TestServer) { reason: "Organization over quota.".into() }] ); + test.wait_for_tasks().await; // Delete everything created during the test for (admin, tenant_id_pos) in [(&admin_x, 0), (&admin_y, 1)] { diff --git a/tests/src/utils/account.rs b/tests/src/utils/account.rs index 4b616f10..cc9c5653 100644 --- a/tests/src/utils/account.rs +++ b/tests/src/utils/account.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::utils::server::TestServer; +use crate::utils::{server::TestServer, webdav::DummyWebDavClient}; use ahash::AHashMap; use jmap_client::client::{Client, Credentials}; use registry::{ @@ -27,17 +27,16 @@ pub struct Account { emails: &'static [&'static str], id: Id, id_string: String, - client: Client, } impl TestServer { pub async fn create_user_account( - &mut self, + &self, using_account: &str, name: &'static str, secret: &'static str, aliases: &'static [&'static str], - ) -> Id { + ) -> Account { let mut domains = AHashMap::from_iter( aliases .iter() @@ -82,38 +81,27 @@ impl TestServer { })) .await; - self.accounts - .insert(name, Account::new(name, secret, aliases, account_id).await); + Account::new(name, secret, aliases, account_id) + } - account_id + pub fn insert_account(&mut self, account: Account) { + self.accounts.insert(account.name(), account); } } impl Account { - pub async fn new( + pub fn new( name: &'static str, secret: &'static str, emails: &'static [&'static str], id: Id, ) -> Self { - let id_string = id.to_string(); - - let mut client = Client::new() - .credentials(Credentials::basic(name, secret)) - .timeout(Duration::from_secs(3600)) - .accept_invalid_certs(true) - .follow_redirects(["127.0.0.1"]) - .connect("https://127.0.0.1:8899") - .await - .unwrap(); - client.set_default_account_id(id_string.clone()); Self { name, secret, emails, id, - id_string, - client, + id_string: id.to_string(), } } @@ -121,18 +109,14 @@ impl Account { self.secret = new_secret; } - pub fn id(&self) -> &Id { - &self.id + pub fn id(&self) -> Id { + self.id } pub fn id_string(&self) -> &str { &self.id_string } - pub fn client(&self) -> &Client { - &self.client - } - pub fn name(&self) -> &'static str { self.name } @@ -197,14 +181,25 @@ impl Account { .updated_id(account_id); } - pub async fn client_owned(&self) -> Client { - Client::new() + pub fn webdav_client(&self) -> DummyWebDavClient { + DummyWebDavClient::new( + self.id.document_id(), + self.name(), + self.secret(), + self.emails()[0], + ) + } + + pub async fn jmap_client(&self) -> Client { + let mut client = Client::new() .credentials(Credentials::basic(self.name(), self.secret())) .timeout(Duration::from_secs(3600)) .accept_invalid_certs(true) .follow_redirects(["127.0.0.1"]) .connect("https://127.0.0.1:8899") .await - .unwrap() + .unwrap(); + client.set_default_account_id(self.id_string()); + client } } diff --git a/tests/src/utils/cleanup.rs b/tests/src/utils/cleanup.rs index 971ed387..7c827a1c 100644 --- a/tests/src/utils/cleanup.rs +++ b/tests/src/utils/cleanup.rs @@ -4,6 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use ::registry::{ + schema::prelude::{OBJ_SINGLETON, ObjectType}, + types::EnumImpl, +}; use store::{ ValueKey, write::{key::DeserializeBigEndian, *}, @@ -11,6 +15,7 @@ use store::{ }; use trc::AddContext; use types::blob_hash::{BLOB_HASH_LEN, BlobHash}; +use utils::codec::leb128::Leb128Reader; pub async fn store_destroy(store: &Store) { store_destroy_sql_indexes(store).await; @@ -220,7 +225,7 @@ pub async fn store_lookup_expire_all(store: &Store) { } #[allow(unused_variables)] -pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include_directory: bool) { +pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include_registry: bool) { store_blob_expire_all(store).await; store_lookup_expire_all(store).await; for shard_idx in 0..=u8::MAX { @@ -240,7 +245,6 @@ pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include (SUBSPACE_IN_MEMORY_VALUE, true), (SUBSPACE_IN_MEMORY_COUNTER, false), (SUBSPACE_PROPERTY, true), - (SUBSPACE_REGISTRY, true), (SUBSPACE_QUEUE_MESSAGE, true), (SUBSPACE_QUEUE_EVENT, true), (SUBSPACE_REPORT_OUT, true), @@ -255,13 +259,12 @@ pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include (SUBSPACE_TELEMETRY_SPAN, true), (SUBSPACE_TELEMETRY_METRIC, true), (SUBSPACE_SEARCH_INDEX, true), + (SUBSPACE_REGISTRY, true), (SUBSPACE_REGISTRY_IDX, false), (SUBSPACE_REGISTRY_PK, true), (SUBSPACE_DIRECTORY, true), ] { - if subspace == SUBSPACE_SEARCH_INDEX && store.is_pg_or_mysql() - //|| (subspace == directory && !include_directory) - { + if subspace == SUBSPACE_SEARCH_INDEX && store.is_pg_or_mysql() { continue; } @@ -301,6 +304,52 @@ pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include key ); } + SUBSPACE_REGISTRY | SUBSPACE_DIRECTORY => { + let object_id = + ObjectType::from_id(key.deserialize_be_u16(0).unwrap()).unwrap(); + + if include_registry && is_allowed_registry_type(object_id) { + return Ok(true); + } + let item_id = key.read_leb128::().unwrap().0; + + println!( + "Found registry item for object type {:?} and id {}", + object_id, item_id + ); + } + SUBSPACE_REGISTRY_IDX => { + let mut id = key.deserialize_be_u16(0).unwrap(); + if id == u16::MAX { + id = key.deserialize_be_u16(U16_LEN).unwrap(); + } + + let object_id = ObjectType::from_id(id).unwrap(); + + if include_registry && is_allowed_registry_type(object_id) { + return Ok(true); + } + + println!( + "Found registry index for object type {:?}: {:?}", + object_id, key + ); + } + SUBSPACE_REGISTRY_PK => { + let mut id = key.deserialize_be_u16(0).unwrap(); + if id == u16::MAX { + id = value.deserialize_be_u16(0).unwrap(); + } + let object_id = ObjectType::from_id(id).unwrap(); + if include_registry && is_allowed_registry_type(object_id) { + return Ok(true); + } + + println!( + "Found registry primary key for object type {:?}: {:?}", + object_id, key + ); + } _ => { println!( "Found key in {:?}: {:?} ({:?}) = {:?} ({:?})", @@ -362,3 +411,19 @@ pub async fn store_assert_is_empty(store: &Store, blob_store: BlobStore, include panic!("Store is not empty."); } } + +fn is_allowed_registry_type(object_type: ObjectType) -> bool { + (object_type.flags() & OBJ_SINGLETON) != 0 + || matches!( + object_type, + ObjectType::Role + | ObjectType::Account + | ObjectType::NetworkListener + | ObjectType::MtaDeliverySchedule + | ObjectType::MtaRoute + | ObjectType::MtaTlsStrategy + | ObjectType::MtaVirtualQueue + | ObjectType::Tracer + | ObjectType::Domain + ) +} diff --git a/tests/src/utils/imap.rs b/tests/src/utils/imap.rs index 008e138b..921e3af8 100644 --- a/tests/src/utils/imap.rs +++ b/tests/src/utils/imap.rs @@ -145,3 +145,171 @@ impl ImapConnection { self.assert_read(Type::Tagged, ResponseType::Ok).await; } } + +pub trait AssertResult: Sized { + fn assert_folders<'x>( + self, + expected: impl IntoIterator)>, + match_all: bool, + ) -> Self; + + fn assert_response_code(self, code: &str) -> Self; + fn assert_contains(self, text: &str) -> Self; + fn assert_count(self, text: &str, occurrences: usize) -> Self; + fn assert_equals(self, text: &str) -> Self; + fn into_response_code(self) -> String; + fn into_highest_modseq(self) -> String; + fn into_uid_validity(self) -> String; + fn into_append_uid(self) -> String; + fn into_copy_uid(self) -> String; + fn into_modseq(self) -> String; +} + +impl AssertResult for Vec { + fn assert_folders<'x>( + self, + expected: impl IntoIterator)>, + match_all: bool, + ) -> Self { + let mut match_count = 0; + 'outer: for (mailbox_name, flags) in expected.into_iter() { + for result in self.iter() { + if result.contains(&format!("\"{}\"", mailbox_name)) { + for flag in flags { + if !flag.is_empty() && !result.contains(flag) { + panic!("Expected mailbox {} to have flag {}", mailbox_name, flag); + } + } + match_count += 1; + continue 'outer; + } + } + panic!("Mailbox {} is not present.", mailbox_name); + } + if match_all && match_count != self.len() - 1 { + panic!( + "Expected {} mailboxes, but got {}: {:?}", + match_count, + self.len() - 1, + self.iter().collect::>() + ); + } + self + } + + fn assert_response_code(self, code: &str) -> Self { + if !self.last().unwrap().contains(&format!("[{}]", code)) { + panic!( + "Response code {:?} not found, got {:?}", + code, + self.last().unwrap() + ); + } + self + } + + fn assert_contains(self, text: &str) -> Self { + for line in &self { + if line.contains(text) { + return self; + } + } + panic!("Expected response to contain {:?}, got {:?}", text, self); + } + + fn assert_count(self, text: &str, occurrences: usize) -> Self { + assert_eq!( + self.iter().filter(|l| l.contains(text)).count(), + occurrences, + "Expected {} occurrences of {:?}, found {} in {:?}.", + occurrences, + text, + self.iter().filter(|l| l.contains(text)).count(), + self + ); + self + } + + fn assert_equals(self, text: &str) -> Self { + for line in &self { + if line == text { + return self; + } + } + panic!("Expected response to be {:?}, got {:?}", text, self); + } + + fn into_response_code(self) -> String { + if let Some((_, code)) = self.last().unwrap().split_once('[') + && let Some((code, _)) = code.split_once(']') + { + return code.to_string(); + } + panic!("No response code found in {:?}", self.last().unwrap()); + } + + fn into_append_uid(self) -> String { + if let Some((_, code)) = self.last().unwrap().split_once("[APPENDUID ") + && let Some((code, _)) = code.split_once(']') + && let Some((_, uid)) = code.split_once(' ') + { + return uid.to_string(); + } + panic!("No APPENDUID found in {:?}", self.last().unwrap()); + } + + fn into_copy_uid(self) -> String { + for line in &self { + if let Some((_, code)) = line.split_once("[COPYUID ") + && let Some((code, _)) = code.split_once(']') + && let Some((_, uid)) = code.rsplit_once(' ') + { + return uid.to_string(); + } + } + panic!("No COPYUID found in {:?}", self); + } + + fn into_highest_modseq(self) -> String { + for line in &self { + if let Some((_, value)) = line.split_once("HIGHESTMODSEQ ") { + if let Some((value, _)) = value.split_once(']') { + return value.to_string(); + } else if let Some((value, _)) = value.split_once(')') { + return value.to_string(); + } else { + panic!("No HIGHESTMODSEQ delimiter found in {:?}", line); + } + } + } + panic!("No HIGHESTMODSEQ entries found in {:?}", self); + } + + fn into_modseq(self) -> String { + for line in &self { + if let Some((_, value)) = line.split_once("MODSEQ (") { + if let Some((value, _)) = value.split_once(')') { + return value.to_string(); + } else { + panic!("No MODSEQ delimiter found in {:?}", line); + } + } + } + panic!("No MODSEQ entries found in {:?}", self); + } + + fn into_uid_validity(self) -> String { + for line in &self { + if let Some((_, value)) = line.split_once("UIDVALIDITY ") { + if let Some((value, _)) = value.split_once(']') { + return value.to_string(); + } else if let Some((value, _)) = value.split_once(')') { + return value.to_string(); + } else { + panic!("No UIDVALIDITY delimiter found in {:?}", line); + } + } + } + panic!("No UIDVALIDITY entries found in {:?}", self); + } +} diff --git a/tests/src/utils/mod.rs b/tests/src/utils/mod.rs index c7b28293..3f607c0d 100644 --- a/tests/src/utils/mod.rs +++ b/tests/src/utils/mod.rs @@ -14,3 +14,4 @@ pub mod registry; pub mod server; pub mod smtp; pub mod storage; +pub mod webdav; diff --git a/tests/src/utils/registry.rs b/tests/src/utils/registry.rs index 941ba762..5fc0da63 100644 --- a/tests/src/utils/registry.rs +++ b/tests/src/utils/registry.rs @@ -120,7 +120,7 @@ impl Account { pub async fn registry_destroy_all(&self, object: ObjectType) { let name = object.as_str(); self.jmap_method_calls(json!([[ - format!("{name}/get"), + format!("x:{name}/get"), { "ids" : (), "properties" : [ @@ -130,11 +130,11 @@ impl Account { "R1" ], [ - format!("{name}/set"), + format!("x:{name}/set"), { "#destroy" : { "resultOf": "R1", - "name": format!("{name}/get"), + "name": format!("x:{name}/get"), "path": "/list/*/id" }, }, @@ -212,6 +212,13 @@ impl Account { .to_string(); serde_json::from_str(&v).expect("Failed to deserialize set error") } + + pub async fn destroy_account(&self, account: Account) { + let account_id = account.id(); + self.registry_destroy(ObjectType::Account, [account_id]) + .await + .assert_destroyed(&[account_id]); + } } impl JmapResponse { diff --git a/tests/src/utils/server.rs b/tests/src/utils/server.rs index 408c654d..1f554aaa 100644 --- a/tests/src/utils/server.rs +++ b/tests/src/utils/server.rs @@ -282,7 +282,7 @@ impl TestServerBuilder { temp_dir: self.temp_dir, accounts: AHashMap::from_iter([( "admin", - Account::new("admin", "popolna_zapora", &[], Id::from(FALLBACK_ADMIN_ID)).await, + Account::new("admin", "popolna_zapora", &[], Id::from(FALLBACK_ADMIN_ID)), )]), shutdown_tx, reset: self.reset, @@ -304,7 +304,7 @@ impl TestServer { } pub async fn assert_is_empty(&self) { - assert_is_empty(&self.server).await; + assert_is_empty(&self.server, true).await; } pub async fn destroy_store(&self) { @@ -330,7 +330,7 @@ impl TestServer { pub async fn destroy_all_mailboxes(&self, account: &Account) { self.wait_for_tasks().await; - destroy_all_mailboxes_no_wait(account.client()).await; + destroy_all_mailboxes_no_wait(&account.jmap_client().await).await; } } diff --git a/tests/src/utils/storage.rs b/tests/src/utils/storage.rs index a5a174cc..620a59e1 100644 --- a/tests/src/utils/storage.rs +++ b/tests/src/utils/storage.rs @@ -21,7 +21,6 @@ use registry::{ }, types::{EnumImpl, duration::Duration}, }; -use store::U64_LEN; use store::{ Deserialize, IterateParams, ValueKey, write::{TaskQueueClass, ValueClass}, @@ -168,10 +167,8 @@ pub async fn wait_for_tasks(server: &Server) { ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task { id: u64::MAX })), ) .ascending(), - |key, value| { - if key.len() == U64_LEN { - has_index_tasks = Some(Task::deserialize(value)?); - } + |_, value| { + has_index_tasks = Some(Task::deserialize(value)?); Ok(false) }, @@ -191,12 +188,17 @@ pub async fn wait_for_tasks(server: &Server) { } } -pub async fn assert_is_empty(server: &Server) { +pub async fn assert_is_empty(server: &Server, include_registry: bool) { // Wait for pending index tasks wait_for_tasks(server).await; // Assert is empty - store_assert_is_empty(server.store(), server.core.storage.blob.clone(), false).await; + store_assert_is_empty( + server.store(), + server.core.storage.blob.clone(), + include_registry, + ) + .await; search_store_destroy(server.search_store()).await; // Clean caches diff --git a/tests/src/utils/webdav.rs b/tests/src/utils/webdav.rs new file mode 100644 index 00000000..3bac9672 --- /dev/null +++ b/tests/src/utils/webdav.rs @@ -0,0 +1,1386 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::{borrow::Cow, time::Duration}; + +use ahash::{AHashMap, AHashSet}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use dav_proto::{ + Depth, + schema::property::{CalDavProperty, DavProperty, WebDavProperty}, + xml_pretty_print, +}; +use groupware::DavResourceName; +use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION}; +use quick_xml::{Reader, events::Event}; +use store::rand::{Rng, distr::Alphanumeric, rng}; + +#[allow(dead_code)] +#[derive(Debug)] +pub struct DummyWebDavClient { + account_id: u32, + name: &'static str, + email: &'static str, + credentials: String, +} + +#[derive(Debug)] +pub struct DavResponse { + headers: AHashMap, + status: StatusCode, + body: Result, + xml: Vec<(String, String)>, +} + +#[derive(Debug)] +pub struct DavMultiStatus { + pub response: DavResponse, + pub hrefs: AHashMap, +} + +#[derive(Debug, serde::Serialize)] +pub struct DavItem { + #[serde(serialize_with = "serialize_status_code")] + pub status: StatusCode, + pub values: AHashMap>, + pub error: Vec, + pub description: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct DavProperties { + #[serde(skip)] + status: StatusCode, + props: Vec, +} + +pub struct DavPropertyResult<'x> { + pub response: &'x DavResponse, + pub properties: &'x DavProperties, +} + +pub struct DavQueryResult<'x> { + pub response: &'x DavResponse, + pub prop: &'x DavItem, + pub values: &'x [String], +} + +impl DummyWebDavClient { + pub fn new( + account_id: u32, + name: &'static str, + secret: &'static str, + email: &'static str, + ) -> Self { + Self { + account_id, + name, + email, + credentials: format!( + "Basic {}", + STANDARD.encode(format!("{name}:{secret}").as_bytes()) + ), + } + } + + pub async fn request(&self, method: &str, query: &str, body: impl Into) -> DavResponse { + self.request_with_headers(method, query, [].into_iter(), body) + .await + } + + pub async fn request_with_headers( + &self, + method: &str, + query: &str, + headers: impl IntoIterator, + body: impl Into, + ) -> DavResponse { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request( + Method::from_bytes(method.as_bytes()).unwrap(), + format!("https://127.0.0.1:8899{query}"), + ); + + let body = body.into(); + if !body.is_empty() { + request = request.body(body); + } + + let mut request_headers = HeaderMap::new(); + for (key, value) in headers { + request_headers.insert(key, value.parse().unwrap()); + } + request_headers.insert(AUTHORIZATION, self.credentials.parse().unwrap()); + + let response = request.headers(request_headers).send().await.unwrap(); + let status = response.status(); + let headers = response + .headers() + .iter() + .map(|(k, v)| { + ( + k.to_string().to_lowercase(), + v.to_str().unwrap().to_string(), + ) + }) + .collect(); + let body = response + .bytes() + .await + .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) + .map_err(|err| err.to_string()); + let xml = match &body { + Ok(body) if body.starts_with(" flatten_xml(body), + _ => vec![], + }; + + DavResponse { + headers, + status, + body, + xml, + } + } + + pub async fn available_quota(&self, path: &str) -> u64 { + self.propfind( + path, + [DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)], + ) + .await + .properties(path) + .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) + .value() + .parse() + .unwrap() + } + + pub async fn create_hierarchy( + &self, + base_path: &str, + max_depth: usize, + containers_per_level: usize, + files_per_container: usize, + ) -> (String, Vec<(String, String)>) { + let resource_type = if base_path.starts_with("/dav/card/") { + DavResourceName::Card + } else if base_path.starts_with("/dav/cal/") { + DavResourceName::Cal + } else { + DavResourceName::File + }; + + let mut created_resources = Vec::new(); + + self.create_hierarchy_recursive( + resource_type, + base_path, + max_depth, + containers_per_level, + files_per_container, + 0, + &mut created_resources, + ) + .await; + + let root_folder = created_resources.first().unwrap().0.clone(); + created_resources.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + (root_folder, created_resources) + } + + #[allow(clippy::too_many_arguments)] + async fn create_hierarchy_recursive( + &self, + resource_type: DavResourceName, + base_path: &str, + max_depth: usize, + containers_per_level: usize, + files_per_container: usize, + current_depth: usize, + created_resources: &mut Vec<(String, String)>, + ) { + let folder_name = generate_random_name(4); + let folder_path = format!("{base_path}/Folder_{folder_name}"); + + self.mkcol("MKCOL", &folder_path, [], []) + .await + .with_status(StatusCode::CREATED); + + created_resources.push((format!("{folder_path}/"), "".to_string())); + + for _ in 0..files_per_container { + let file_name = generate_random_name(8); + let file_path = format!( + "{folder_path}/{file_name}.{}", + match resource_type { + DavResourceName::Card => "vcf", + DavResourceName::Cal => "ics", + DavResourceName::File => "txt", + _ => unreachable!(), + } + ); + let content = match resource_type { + DavResourceName::Card => generate_random_vcard(), + DavResourceName::Cal => generate_random_ical(), + DavResourceName::File => generate_random_content(100, 500), + _ => unreachable!(), + }; + + self.request("PUT", &file_path, &content) + .await + .with_status(StatusCode::CREATED); + + created_resources.push((file_path, content)); + } + + if current_depth < max_depth { + for _ in 0..containers_per_level { + Box::pin(self.create_hierarchy_recursive( + resource_type, + &folder_path, + max_depth, + containers_per_level, + files_per_container, + current_depth + 1, + created_resources, + )) + .await; + } + } + } + + pub async fn validate_values(&self, items: &[(String, String)]) { + for (path, value) in items { + if !path.ends_with('/') { + self.request("GET", path, "") + .await + .with_status(StatusCode::OK) + .with_body(value); + } + } + } + + pub async fn delete_default_containers(&self) { + self.delete_default_containers_by_account(self.name).await; + } + + pub async fn delete_default_containers_by_account(&self, account: &str) { + for col in ["card", "cal"] { + self.request("DELETE", &format!("/dav/{col}/{account}/default"), "") + .await + .with_status(StatusCode::NO_CONTENT); + } + } + + pub async fn lock_create( + &self, + path: &str, + owner: &str, + is_exclusive: bool, + depth: &str, + timeout: &str, + ) -> DavResponse { + let lock_request = LOCK_REQUEST + .replace("$TYPE", if is_exclusive { "exclusive" } else { "shared" }) + .replace("$OWNER", owner); + self.request_with_headers( + "LOCK", + path, + [("depth", depth), ("timeout", timeout)], + &lock_request, + ) + .await + } + + pub async fn lock_refresh( + &self, + path: &str, + lock_token: &str, + depth: &str, + timeout: &str, + ) -> DavResponse { + let condition = format!("(<{lock_token}>)"); + self.request_with_headers( + "LOCK", + path, + [ + ("if", condition.as_str()), + ("depth", depth), + ("timeout", timeout), + ], + "", + ) + .await + } + + pub async fn unlock(&self, path: &str, lock_token: &str) -> DavResponse { + let condition = format!("<{lock_token}>"); + self.request_with_headers("UNLOCK", path, [("lock-token", condition.as_str())], "") + .await + } + + pub async fn mkcol( + &self, + method: &str, + path: &str, + resource_types: impl IntoIterator, + properties: impl IntoIterator, + ) -> DavResponse { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + let mut has_resource_type = false; + for (idx, resource_type) in resource_types.into_iter().enumerate() { + if idx == 0 { + request.push_str(""); + } + request.push_str(&format!("<{resource_type}/>")); + has_resource_type = true; + } + + if has_resource_type { + request.push_str(""); + } + + for (key, value) in properties { + request.push_str(&format!("<{key}>{value}")); + } + request.push_str(""); + + if method == "MKCALENDAR" { + request = request.replace("D:mkcol", "A:mkcalendar"); + } + + self.request(method, path, &request).await + } + + pub async fn patch_and_check( + &self, + path: &str, + properties: impl IntoIterator, + ) where + T: AsRef + Clone, + { + let mut expect_set = Vec::new(); + let mut expect_remove = Vec::new(); + + for (key, value) in properties { + if !value.is_empty() { + expect_set.push((key, value)); + } else { + expect_remove.push(key); + } + } + + let response = self + .proppatch( + path, + expect_set.iter().cloned(), + expect_remove.iter().cloned(), + [], + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None); + let patch_prop = response.properties(path); + for (key, _) in &expect_set { + patch_prop.get(key.as_ref()).with_status(StatusCode::OK); + } + for key in &expect_remove { + patch_prop + .get(key.as_ref()) + .with_status(StatusCode::NO_CONTENT); + } + + let response = self + .propfind( + path, + expect_set + .iter() + .map(|(k, _)| k) + .chain(expect_remove.iter()), + ) + .await; + let prop = response.properties(path); + + for (key, value) in expect_set { + prop.get(key.as_ref()) + .with_values([value]) + .with_status(StatusCode::OK); + } + + for key in expect_remove { + prop.get(key.as_ref()).with_status(StatusCode::NOT_FOUND); + } + } + + pub async fn propfind(&self, path: &str, properties: I) -> DavMultiStatus + where + I: IntoIterator, + T: AsRef, + { + self.propfind_with_headers(path, properties, []).await + } + + pub async fn propfind_with_headers( + &self, + path: &str, + properties: I, + headers: impl IntoIterator, + ) -> DavMultiStatus + where + I: IntoIterator, + T: AsRef, + { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in properties { + request.push_str(&format!("<{}/>", property.as_ref())); + } + + request.push_str(""); + + self.request_with_headers("PROPFIND", path, headers, &request) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn proppatch( + &self, + path: &str, + set: impl IntoIterator, + clear: impl IntoIterator, + headers: impl IntoIterator, + ) -> DavResponse + where + T: AsRef, + { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in clear { + request.push_str(&format!("<{}/>", property.as_ref())); + } + + request.push_str(""); + + for (key, value) in set { + let key = key.as_ref(); + request.push_str(&format!("<{key}>{value}")); + } + + request.push_str(""); + + self.request_with_headers("PROPPATCH", path, headers, &request) + .await + } + + pub async fn multiget_calendar(&self, path: &str, uris: &[&str]) -> DavMultiStatus { + let mut paths = String::new(); + for uri in uris { + paths.push_str(&format!("{}", uri)); + } + + self.request("REPORT", path, &MULTIGET_CALENDAR.replace("$PATH", &paths)) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn multiget_addressbook(&self, path: &str, uris: &[&str]) -> DavMultiStatus { + let mut paths = String::new(); + for uri in uris { + paths.push_str(&format!("{}", uri)); + } + + self.request( + "REPORT", + path, + &MULTIGET_ADDRESSBOOK.replace("$PATH", &paths), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .into_propfind_response(None) + } + + pub async fn sync_collection( + &self, + path: &str, + sync_token: &str, + depth: Depth, + limit: Option, + properties: impl IntoIterator, + ) -> DavResponse { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in properties { + request.push_str(&format!("<{property}/>")); + } + + request.push_str(""); + request.push_str(sync_token); + request.push_str(""); + request.push_str(match depth { + Depth::One => "1", + Depth::Infinity => "infinite", + _ => "0", + }); + request.push_str(""); + + if let Some(limit) = limit { + request.push_str(""); + request.push_str(&limit.to_string()); + request.push_str(""); + } + + request.push_str(""); + + self.request("REPORT", path, &request) + .await + .with_status(StatusCode::MULTI_STATUS) + } + + pub async fn acl<'x>( + &self, + query: &str, + principal_href: &str, + grant: impl IntoIterator, + ) -> DavResponse { + let body = ACL_QUERY.replace("$HREF", principal_href).replace( + "$GRANT", + &grant.into_iter().fold(String::new(), |mut output, g| { + use std::fmt::Write; + let _ = write!(output, ""); + output + }), + ); + self.request("ACL", query, &body).await + } +} + +impl DavResponse { + pub fn with_status(self, status: StatusCode) -> Self { + if self.status != status { + self.dump_response(); + panic!("Expected {status} but got {}", self.status) + } + self + } + + pub fn with_redirect_to(self, url: &str) -> Self { + self.with_status(StatusCode::TEMPORARY_REDIRECT) + .with_header("location", url) + } + + pub fn with_header(self, header: &str, value: &str) -> Self { + if self.headers.get(header).is_some_and(|v| v == value) { + self + } else { + self.dump_response(); + panic!("Header {header}:{value} not found.") + } + } + + pub fn with_body(self, expect_body: impl AsRef) -> Self { + let expect_body = expect_body.as_ref(); + if self.body.is_ok() { + let body = self.body.as_ref().unwrap(); + if body != expect_body { + self.dump_response(); + assert_eq!(body, &expect_body); + } + self + } else { + self.dump_response(); + panic!("Expected body {expect_body:?} but no body was returned.") + } + } + + pub fn with_empty_body(self) -> Self { + if self.body.is_ok() { + let body = self.body.as_ref().unwrap(); + if !body.is_empty() { + self.dump_response(); + panic!("Expected empty body but got {body:?}"); + } + self + } else { + self.dump_response(); + panic!("Expected empty body but no body was returned.") + } + } + + pub fn expect_body(&self) -> &str { + if self.body.is_ok() { + self.body.as_ref().unwrap() + } else { + self.dump_response(); + panic!("Expected body but no body was returned.") + } + } + + pub fn header(&self, header: &str) -> &str { + if let Some(value) = self.headers.get(header) { + value + } else { + self.dump_response(); + panic!("Header {header} not found.") + } + } + + pub fn etag(&self) -> &str { + self.header("etag") + } + + pub fn sync_token(&self) -> &str { + self.find_keys("D:multistatus.D:sync-token") + .next() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| { + self.dump_response(); + panic!("Sync token not found.") + }) + } + + pub fn hrefs(&self) -> Vec<&str> { + let mut hrefs = self + .find_keys("D:multistatus.D:response.D:href") + .collect::>(); + hrefs.sort_unstable(); + hrefs + } + + pub fn with_href_count(self, count: usize) -> Self { + let href_count = self.find_keys("D:multistatus.D:response.D:href").count(); + if href_count != count { + self.dump_response(); + panic!("Expected {} hrefs but got {}", count, href_count); + } + self + } + + pub fn with_hrefs<'x>(self, hrefs: impl IntoIterator) -> Self { + let expected_hrefs = hrefs.into_iter().collect::>(); + let hrefs = self + .find_keys("D:multistatus.D:response.D:href") + .collect::>(); + if expected_hrefs != hrefs { + self.dump_response(); + + println!("\nMissing: {:?}", expected_hrefs.difference(&hrefs)); + println!("\nExtra: {:?}", hrefs.difference(&expected_hrefs)); + + panic!( + "Hierarchy mismatch: expected {} items, received {} items", + expected_hrefs.len(), + hrefs.len() + ); + } + self + } + + fn dump_response(&self) { + eprintln!("-------------------------------------"); + eprintln!("Status: {}", self.status); + eprintln!("Headers:"); + for (key, value) in self.headers.iter() { + eprintln!(" {}: {:?}", key, value); + } + if !self.xml.is_empty() { + eprintln!("XML: {}", xml_pretty_print(self.body.as_ref().unwrap())); + + for (key, value) in self.xml.iter() { + eprintln!("{} -> {:?}", key, value); + } + } else { + eprintln!("Body: {:?}", self.body); + } + } + + fn find_keys(&self, name: &str) -> impl Iterator { + self.xml + .iter() + .filter(move |(key, _)| name == key) + .map(|(_, value)| value.as_str()) + } + + pub fn value(&self, name: &str) -> &str { + self.find_keys(name).next().unwrap_or_else(|| { + self.dump_response(); + panic!("Key {name} not found.") + }) + } + + // Poor man's XPath + pub fn with_value(self, query: &str, expect: impl AsRef) -> Self { + let expect = expect.as_ref(); + if let Some(value) = self.find_keys(query).next() { + if value != expect { + self.dump_response(); + panic!("Expected {query} = {expect:?} but got {value:?}"); + } + } else { + self.dump_response(); + panic!("Key {query} not found."); + } + self + } + + pub fn with_any_value<'x>( + self, + query: &str, + expect: impl IntoIterator, + ) -> Self { + let expect = expect.into_iter().collect::>(); + if let Some(value) = self.find_keys(query).next() { + if !expect.contains(value) { + self.dump_response(); + panic!("Expected {query} = {expect:?} but got {value:?}"); + } + } else { + self.dump_response(); + panic!("Key {query} not found."); + } + self + } + + pub fn with_values(self, query: &str, expect: I) -> Self + where + I: IntoIterator, + T: AsRef, + { + let expect_owned: Vec = expect.into_iter().collect(); + let expect = expect_owned.iter().map(|s| s.as_ref()).collect::>(); + let found = self.find_keys(query).collect::>(); + if expect != found { + self.dump_response(); + panic!("Expected {query} = {expect:?} but got {found:?}"); + } + self + } + + pub fn with_failed_precondition(self, precondition: &str, value: &str) -> Self { + let error = format!("D:error.{precondition}"); + if self.find_keys(&error).next().is_none_or(|v| v != value) { + self.dump_response(); + panic!("Precondition {precondition} did not match."); + } + self + } + + pub fn into_propfind_response(mut self, prop_prefix: Option<&str>) -> DavMultiStatus { + if let Some(prop_prefix) = prop_prefix { + for (key, _) in self.xml.iter_mut() { + if let Some(suffix) = key.strip_prefix(prop_prefix) { + *key = format!("D:multistatus.D:response{suffix}"); + } + } + self.xml.push(( + "D:multistatus.D:response.D:href".to_string(), + "".to_string(), + )); + } + + let mut result = DavMultiStatus { + response: self, + hrefs: AHashMap::new(), + }; + let mut href = None; + let mut href_status = StatusCode::OK; + let mut props = Vec::new(); + let mut prop = DavItem::default(); + + for (key, value) in &result.response.xml { + match key.as_str() { + "D:multistatus.D:response.D:href" => { + if let Some(href) = href.take() { + if !prop.is_empty() { + props.push(std::mem::take(&mut prop)); + } + result.hrefs.insert( + href, + DavProperties { + status: href_status, + props: std::mem::take(&mut props), + }, + ); + href_status = StatusCode::OK; + } + href = Some(value.to_string()); + } + "D:multistatus.D:response.D:status" => { + href_status = value + .split_ascii_whitespace() + .nth(1) + .unwrap_or_default() + .parse() + .unwrap(); + } + "D:multistatus.D:response.D:propstat.D:status" => { + prop.status = value + .split_ascii_whitespace() + .nth(1) + .unwrap_or_default() + .parse() + .unwrap(); + } + "D:multistatus.D:response.D:propstat.D:responsedescription" => { + prop.description = Some(value.to_string()); + } + _ => { + if let Some(prop_name) = + key.strip_prefix("D:multistatus.D:response.D:propstat.D:prop.") + { + if prop.status != StatusCode::PROXY_AUTHENTICATION_REQUIRED { + props.push(std::mem::take(&mut prop)); + } + + let (prop_name, prop_value) = + if let Some((prop_name, prop_sub_name)) = prop_name.split_once('.') { + if value.is_empty() { + (prop_name, prop_sub_name.to_string()) + } else { + (prop_name, format!("{}:{}", prop_sub_name, value)) + } + } else { + (prop_name, value.to_string()) + }; + prop.values + .entry(prop_name.to_string()) + .or_default() + .push(prop_value); + } + } + } + } + + if let Some(href) = href.take() { + if !prop.is_empty() { + props.push(prop); + } + result.hrefs.insert( + href, + DavProperties { + status: href_status, + props, + }, + ); + } + + result + } +} + +impl DavPropertyResult<'_> { + pub fn get(&self, name: impl AsRef) -> DavQueryResult<'_> { + let name = name.as_ref(); + self.properties + .props + .iter() + .find_map(|prop| { + prop.values.get(name).map(|values| DavQueryResult { + response: self.response, + prop, + values, + }) + }) + .unwrap_or_else(|| { + self.response.dump_response(); + panic!( + "No property found for name: {name} in {}", + serde_json::to_string_pretty(&self.properties.props).unwrap() + ) + }) + } + + pub fn with_status(&self, status: StatusCode) -> &Self { + if self.properties.status != status { + self.response.dump_response(); + panic!( + "Expected status {status}, but got {}", + self.properties.status + ); + } + self + } + + pub fn is_defined(&self, name: impl AsRef) -> &Self { + if self + .properties + .props + .iter() + .any(|prop| prop.values.contains_key(name.as_ref())) + { + self + } else { + self.response.dump_response(); + panic!("Expected property {} to be defined", name.as_ref()); + } + } + + pub fn is_undefined(&self, name: impl AsRef) -> &Self { + if self + .properties + .props + .iter() + .any(|prop| prop.values.contains_key(name.as_ref())) + { + self.response.dump_response(); + panic!("Expected property {} to be undefined", name.as_ref()); + } + self + } + + pub fn calendar_data(&self) -> DavQueryResult<'_> { + self.get(DavProperty::CalDav(CalDavProperty::CalendarData( + Default::default(), + ))) + } +} + +impl<'x> DavQueryResult<'x> { + pub fn with_values(&self, expected_values: impl IntoIterator) -> &Self { + let expected_values = AHashSet::from_iter(expected_values); + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + if values != expected_values { + self.response.dump_response(); + assert_eq!(values, expected_values,); + } + self + } + + pub fn with_some_values(&self, expected_values: impl IntoIterator) -> &Self { + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + for expected_value in expected_values { + if !values.contains(expected_value) { + self.response.dump_response(); + panic!("Expected at least one of {expected_value:?} values, but got {values:?}",); + } + } + + self + } + + pub fn with_any_values(&self, expected_values: impl IntoIterator) -> &Self { + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + let expected_values = AHashSet::from_iter(expected_values); + + if values.is_disjoint(&expected_values) { + self.response.dump_response(); + panic!("Expected at least one of {expected_values:?} values, but got {values:?}",); + } + + self + } + + pub fn without_values(&self, expected_values: impl IntoIterator) -> &Self { + let expected_values = AHashSet::from_iter(expected_values); + let values = self + .values + .iter() + .map(|s| s.as_str()) + .collect::>(); + + if !expected_values.is_disjoint(&values) { + self.response.dump_response(); + panic!("Expected no {expected_values:?} values, but got {values:?}",); + } + self + } + + pub fn is_not_empty(&self) -> &Self { + if self.values.is_empty() || self.values.iter().all(|s| s.is_empty()) { + self.response.dump_response(); + panic!("Expected non-empty values, but got {:?}", self.values); + } + self + } + + pub fn value(&self) -> &str { + if let Some(value) = self.values.iter().find(|s| !s.is_empty()) { + value + } else { + self.response.dump_response(); + panic!("Expected a value, but got {:?}", self.values); + } + } + + pub fn with_status(&self, status: StatusCode) -> &Self { + if self.prop.status != status { + self.response.dump_response(); + panic!("Expected status {status}, but got {}", self.prop.status); + } + self + } + + pub fn with_description(&self, description: &str) -> &Self { + if self.prop.description.as_deref() != Some(description) { + self.response.dump_response(); + panic!( + "Expected description {description}, but got {:?}", + self.prop.description + ); + } + self + } + pub fn with_error(&self, error: &str) -> &Self { + if !self.prop.error.contains(&error.to_string()) { + self.response.dump_response(); + panic!("Expected error {error}, but got {:?}", self.prop.error); + } + self + } +} + +impl DavMultiStatus { + pub fn properties(&self, href: &str) -> DavPropertyResult<'_> { + DavPropertyResult { + response: &self.response, + properties: self.hrefs.get(href).unwrap_or_else(|| { + self.response.dump_response(); + panic!( + "No properties found for href: {href} in {}", + serde_json::to_string_pretty(&self.hrefs).unwrap() + ) + }), + } + } + + pub fn with_hrefs<'x>(&self, expect_hrefs: impl IntoIterator) -> &Self { + let expect_hrefs: AHashSet<_> = expect_hrefs.into_iter().collect(); + let hrefs: AHashSet<_> = self.hrefs.keys().map(|s| s.as_str()).collect(); + if hrefs != expect_hrefs { + self.response.dump_response(); + panic!("Expected hrefs {expect_hrefs:?}, but got {hrefs:?}",); + } + self + } +} + +impl DavItem { + pub fn is_empty(&self) -> bool { + self.values.is_empty() + && self.status == StatusCode::PROXY_AUTHENTICATION_REQUIRED + && self.error.is_empty() + && self.description.is_none() + } +} + +impl Default for DavItem { + fn default() -> Self { + DavItem { + status: StatusCode::PROXY_AUTHENTICATION_REQUIRED, + values: AHashMap::new(), + error: Vec::new(), + description: None, + } + } +} + +fn flatten_xml(xml: &str) -> Vec<(String, String)> { + let mut reader = Reader::from_str(xml); + + let mut path: Vec = Vec::new(); + let mut result: Vec<(String, String)> = Vec::new(); + let mut buf = Vec::new(); + let mut text_content: Option = None; + + loop { + match reader.read_event_into(&mut buf).unwrap() { + Event::Start(ref e) => { + let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); + path.push(name); + let base_path = path.join("."); + for attr in e.attributes() { + let attr = attr.unwrap(); + let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); + let value = attr.unescape_value().unwrap(); + let value_str = value.trim().to_string(); + + result.push((format!("{}.[{}]", base_path, key), value_str)); + } + text_content = None; + } + Event::Empty(ref e) => { + let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); + let base_path = format!("{}.{}", path.join("."), name); + let mut has_attrs = false; + + for attr in e.attributes() { + let attr = attr.unwrap(); + let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); + let value = attr.unescape_value().unwrap(); + let value_str = value.trim().to_string(); + has_attrs = true; + result.push((format!("{}.[{}]", base_path, key), value_str)); + } + + if !has_attrs { + result.push((base_path, "".to_string())); + } + } + Event::Text(e) => { + let text = e.xml_content().unwrap(); + let trimmed = text.trim(); + if !trimmed.is_empty() { + if let Some(text_content) = text_content.as_mut() { + text_content.push_str(trimmed); + } else { + text_content = Some(trimmed.to_string()); + } + } + } + Event::GeneralRef(entity) => { + let value: Cow = match entity.as_ref() { + b"lt" => "<".into(), + b"gt" => ">".into(), + b"amp" => "&".into(), + b"apos" => "'".into(), + b"quot" => "\"".into(), + _ => { + if let Ok(Some(gr)) = entity.resolve_char_ref() { + gr.to_string().into() + } else { + std::str::from_utf8(entity.as_ref()) + .unwrap_or_default() + .into() + } + } + }; + + if let Some(text_content) = text_content.as_mut() { + text_content.push_str(value.as_ref()); + } else { + text_content = Some(value.into_owned()); + } + } + Event::CData(e) => { + text_content = Some(std::str::from_utf8(e.as_ref()).unwrap().to_string()); + } + Event::End(_) => { + if let Some(text) = text_content.take() { + result.push((path.join("."), text)); + } + + if !path.is_empty() { + path.pop(); + } + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + + result +} + +pub trait GenerateTestDavResource { + fn generate(&self) -> String; +} + +impl GenerateTestDavResource for DavResourceName { + fn generate(&self) -> String { + match self { + DavResourceName::Card => generate_random_vcard(), + DavResourceName::Cal => generate_random_ical(), + DavResourceName::File => generate_random_content(100, 200), + _ => unreachable!(), + } + } +} + +fn generate_random_vcard() -> String { + r#"BEGIN:VCARD +VERSION:4.0 +UID:$UID +FN:$NAME +END:VCARD +"# + .replace("$UID", &generate_random_name(8)) + .replace("$NAME", &generate_random_name(10)) + .replace('\n', "\r\n") +} + +fn generate_random_ical() -> String { + r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:$UID +SUMMARY:$SUMMARY +DESCRIPTION:$DESCRIPTION +END:VEVENT +END:VCALENDAR +"# + .replace("$UID", &generate_random_name(8)) + .replace("$SUMMARY", &generate_random_name(10)) + .replace("$DESCRIPTION", &generate_random_name(20)) + .replace('\n', "\r\n") +} + +fn generate_random_content(min_chars: usize, max_chars: usize) -> String { + let mut rng = rng(); + let length = rng.random_range(min_chars..=max_chars); + + let words = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua", + "ut", + "enim", + "ad", + "minim", + "veniam", + "quis", + "nostrud", + "exercitation", + "ullamco", + "laboris", + "nisi", + "ut", + "aliquip", + "ex", + "ea", + "commodo", + "consequat", + ]; + + let mut content = String::with_capacity(length); + + while content.len() < length { + let word_idx = rng.random_range(0..words.len()); + if !content.is_empty() { + content.push(' '); + } + if rng.random_ratio(1, 10) { + content.push('.'); + let word = words[word_idx]; + let mut chars = word.chars(); + if let Some(first_char) = chars.next() { + content.push_str(&first_char.to_uppercase().to_string()); + content.push_str(chars.as_str()); + } + } else { + content.push_str(words[word_idx]); + } + } + + if !content.ends_with('.') { + content.push('.'); + } + + content +} + +fn generate_random_name(length: usize) -> String { + let mut rng = rng(); + (0..length) + .map(|_| rng.sample(Alphanumeric) as char) + .collect() +} + +fn serialize_status_code(status_code: &StatusCode, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&status_code.to_string()) +} + +const MULTIGET_CALENDAR: &str = r#" + + + + + + $PATH + +"#; +const MULTIGET_ADDRESSBOOK: &str = r#" + + + + + + $PATH + +"#; + +const ACL_QUERY: &str = r#" + + + + $HREF + + + $GRANT + + + "#; + +const LOCK_REQUEST: &str = r#" + + + + + $OWNER + + "#; diff --git a/tests/src/webdav/acl.rs b/tests/src/webdav/acl.rs index da8ea997..f8b98722 100644 --- a/tests/src/webdav/acl.rs +++ b/tests/src/webdav/acl.rs @@ -399,37 +399,6 @@ pub async fn test(test: &WebDavTest) { test.assert_is_empty().await; } -impl DummyWebDavClient { - pub async fn acl<'x>( - &self, - query: &str, - principal_href: &str, - grant: impl IntoIterator, - ) -> DavResponse { - let body = ACL_QUERY.replace("$HREF", principal_href).replace( - "$GRANT", - &grant.into_iter().fold(String::new(), |mut output, g| { - use std::fmt::Write; - let _ = write!(output, ""); - output - }), - ); - self.request("ACL", query, &body).await - } -} - -const ACL_QUERY: &str = r#" - - - - $HREF - - - $GRANT - - - "#; - const ACL_PRINCIPAL_QUERY: &str = r#" diff --git a/tests/src/webdav/lock.rs b/tests/src/webdav/lock.rs index 5a63fda9..f5188002 100644 --- a/tests/src/webdav/lock.rs +++ b/tests/src/webdav/lock.rs @@ -202,64 +202,6 @@ pub async fn test(test: &WebDavTest) { test.assert_is_empty().await; } -const LOCK_REQUEST: &str = r#" - - - - - $OWNER - - "#; - -impl DummyWebDavClient { - pub async fn lock_create( - &self, - path: &str, - owner: &str, - is_exclusive: bool, - depth: &str, - timeout: &str, - ) -> DavResponse { - let lock_request = LOCK_REQUEST - .replace("$TYPE", if is_exclusive { "exclusive" } else { "shared" }) - .replace("$OWNER", owner); - self.request_with_headers( - "LOCK", - path, - [("depth", depth), ("timeout", timeout)], - &lock_request, - ) - .await - } - - pub async fn lock_refresh( - &self, - path: &str, - lock_token: &str, - depth: &str, - timeout: &str, - ) -> DavResponse { - let condition = format!("(<{lock_token}>)"); - self.request_with_headers( - "LOCK", - path, - [ - ("if", condition.as_str()), - ("depth", depth), - ("timeout", timeout), - ], - "", - ) - .await - } - - pub async fn unlock(&self, path: &str, lock_token: &str) -> DavResponse { - let condition = format!("<{lock_token}>"); - self.request_with_headers("UNLOCK", path, [("lock-token", condition.as_str())], "") - .await - } -} - impl DavResponse { pub fn lock_token(&self) -> &str { self.value("D:prop.D:lockdiscovery.D:activelock.D:locktoken.D:href") diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs index 8c871f10..9e144e16 100644 --- a/tests/src/webdav/mkcol.rs +++ b/tests/src/webdav/mkcol.rs @@ -256,43 +256,3 @@ pub async fn test(test: &WebDavTest) { test.assert_is_empty().await; } -impl DummyWebDavClient { - pub async fn mkcol( - &self, - method: &str, - path: &str, - resource_types: impl IntoIterator, - properties: impl IntoIterator, - ) -> DavResponse { - let mut request = concat!( - "", - "", - "" - ) - .to_string(); - - let mut has_resource_type = false; - for (idx, resource_type) in resource_types.into_iter().enumerate() { - if idx == 0 { - request.push_str(""); - } - request.push_str(&format!("<{resource_type}/>")); - has_resource_type = true; - } - - if has_resource_type { - request.push_str(""); - } - - for (key, value) in properties { - request.push_str(&format!("<{key}>{value}")); - } - request.push_str(""); - - if method == "MKCALENDAR" { - request = request.replace("D:mkcol", "A:mkcalendar"); - } - - self.request(method, path, &request).await - } -} diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 5ef4995c..788ca481 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -282,446 +282,6 @@ impl WebDavTest { } } -#[allow(dead_code)] -#[derive(Debug)] -pub struct DummyWebDavClient { - account_id: u32, - name: &'static str, - email: &'static str, - credentials: String, -} - -#[derive(Debug)] -pub struct DavResponse { - headers: AHashMap, - status: StatusCode, - body: Result, - xml: Vec<(String, String)>, -} - -impl DummyWebDavClient { - pub fn new( - account_id: u32, - name: &'static str, - secret: &'static str, - email: &'static str, - ) -> Self { - Self { - account_id, - name, - email, - credentials: format!( - "Basic {}", - STANDARD.encode(format!("{name}:{secret}").as_bytes()) - ), - } - } - - pub async fn request(&self, method: &str, query: &str, body: impl Into) -> DavResponse { - self.request_with_headers(method, query, [].into_iter(), body) - .await - } - - pub async fn request_with_headers( - &self, - method: &str, - query: &str, - headers: impl IntoIterator, - body: impl Into, - ) -> DavResponse { - let mut request = reqwest::Client::builder() - .timeout(Duration::from_millis(500)) - .danger_accept_invalid_certs(true) - .build() - .unwrap() - .request( - Method::from_bytes(method.as_bytes()).unwrap(), - format!("https://127.0.0.1:8899{query}"), - ); - - let body = body.into(); - if !body.is_empty() { - request = request.body(body); - } - - let mut request_headers = HeaderMap::new(); - for (key, value) in headers { - request_headers.insert(key, value.parse().unwrap()); - } - request_headers.insert(AUTHORIZATION, self.credentials.parse().unwrap()); - - let response = request.headers(request_headers).send().await.unwrap(); - let status = response.status(); - let headers = response - .headers() - .iter() - .map(|(k, v)| { - ( - k.to_string().to_lowercase(), - v.to_str().unwrap().to_string(), - ) - }) - .collect(); - let body = response - .bytes() - .await - .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) - .map_err(|err| err.to_string()); - let xml = match &body { - Ok(body) if body.starts_with(" flatten_xml(body), - _ => vec![], - }; - - DavResponse { - headers, - status, - body, - xml, - } - } - - pub async fn available_quota(&self, path: &str) -> u64 { - self.propfind( - path, - [DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)], - ) - .await - .properties(path) - .get(DavProperty::WebDav(WebDavProperty::QuotaAvailableBytes)) - .value() - .parse() - .unwrap() - } - - pub async fn create_hierarchy( - &self, - base_path: &str, - max_depth: usize, - containers_per_level: usize, - files_per_container: usize, - ) -> (String, Vec<(String, String)>) { - let resource_type = if base_path.starts_with("/dav/card/") { - DavResourceName::Card - } else if base_path.starts_with("/dav/cal/") { - DavResourceName::Cal - } else { - DavResourceName::File - }; - - let mut created_resources = Vec::new(); - - self.create_hierarchy_recursive( - resource_type, - base_path, - max_depth, - containers_per_level, - files_per_container, - 0, - &mut created_resources, - ) - .await; - - let root_folder = created_resources.first().unwrap().0.clone(); - created_resources.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - (root_folder, created_resources) - } - - #[allow(clippy::too_many_arguments)] - async fn create_hierarchy_recursive( - &self, - resource_type: DavResourceName, - base_path: &str, - max_depth: usize, - containers_per_level: usize, - files_per_container: usize, - current_depth: usize, - created_resources: &mut Vec<(String, String)>, - ) { - let folder_name = generate_random_name(4); - let folder_path = format!("{base_path}/Folder_{folder_name}"); - - self.mkcol("MKCOL", &folder_path, [], []) - .await - .with_status(StatusCode::CREATED); - - created_resources.push((format!("{folder_path}/"), "".to_string())); - - for _ in 0..files_per_container { - let file_name = generate_random_name(8); - let file_path = format!( - "{folder_path}/{file_name}.{}", - match resource_type { - DavResourceName::Card => "vcf", - DavResourceName::Cal => "ics", - DavResourceName::File => "txt", - _ => unreachable!(), - } - ); - let content = match resource_type { - DavResourceName::Card => generate_random_vcard(), - DavResourceName::Cal => generate_random_ical(), - DavResourceName::File => generate_random_content(100, 500), - _ => unreachable!(), - }; - - self.request("PUT", &file_path, &content) - .await - .with_status(StatusCode::CREATED); - - created_resources.push((file_path, content)); - } - - if current_depth < max_depth { - for _ in 0..containers_per_level { - Box::pin(self.create_hierarchy_recursive( - resource_type, - &folder_path, - max_depth, - containers_per_level, - files_per_container, - current_depth + 1, - created_resources, - )) - .await; - } - } - } - - pub async fn validate_values(&self, items: &[(String, String)]) { - for (path, value) in items { - if !path.ends_with('/') { - self.request("GET", path, "") - .await - .with_status(StatusCode::OK) - .with_body(value); - } - } - } - - pub async fn delete_default_containers(&self) { - self.delete_default_containers_by_account(self.name).await; - } - - pub async fn delete_default_containers_by_account(&self, account: &str) { - for col in ["card", "cal"] { - self.request("DELETE", &format!("/dav/{col}/{account}/default"), "") - .await - .with_status(StatusCode::NO_CONTENT); - } - } -} - -impl DavResponse { - pub fn with_status(self, status: StatusCode) -> Self { - if self.status != status { - self.dump_response(); - panic!("Expected {status} but got {}", self.status) - } - self - } - - pub fn with_redirect_to(self, url: &str) -> Self { - self.with_status(StatusCode::TEMPORARY_REDIRECT) - .with_header("location", url) - } - - pub fn with_header(self, header: &str, value: &str) -> Self { - if self.headers.get(header).is_some_and(|v| v == value) { - self - } else { - self.dump_response(); - panic!("Header {header}:{value} not found.") - } - } - - pub fn with_body(self, expect_body: impl AsRef) -> Self { - let expect_body = expect_body.as_ref(); - if self.body.is_ok() { - let body = self.body.as_ref().unwrap(); - if body != expect_body { - self.dump_response(); - assert_eq!(body, &expect_body); - } - self - } else { - self.dump_response(); - panic!("Expected body {expect_body:?} but no body was returned.") - } - } - - pub fn with_empty_body(self) -> Self { - if self.body.is_ok() { - let body = self.body.as_ref().unwrap(); - if !body.is_empty() { - self.dump_response(); - panic!("Expected empty body but got {body:?}"); - } - self - } else { - self.dump_response(); - panic!("Expected empty body but no body was returned.") - } - } - - pub fn expect_body(&self) -> &str { - if self.body.is_ok() { - self.body.as_ref().unwrap() - } else { - self.dump_response(); - panic!("Expected body but no body was returned.") - } - } - - pub fn header(&self, header: &str) -> &str { - if let Some(value) = self.headers.get(header) { - value - } else { - self.dump_response(); - panic!("Header {header} not found.") - } - } - - pub fn etag(&self) -> &str { - self.header("etag") - } - - pub fn sync_token(&self) -> &str { - self.find_keys("D:multistatus.D:sync-token") - .next() - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| { - self.dump_response(); - panic!("Sync token not found.") - }) - } - - pub fn hrefs(&self) -> Vec<&str> { - let mut hrefs = self - .find_keys("D:multistatus.D:response.D:href") - .collect::>(); - hrefs.sort_unstable(); - hrefs - } - - pub fn with_href_count(self, count: usize) -> Self { - let href_count = self.find_keys("D:multistatus.D:response.D:href").count(); - if href_count != count { - self.dump_response(); - panic!("Expected {} hrefs but got {}", count, href_count); - } - self - } - - pub fn with_hrefs<'x>(self, hrefs: impl IntoIterator) -> Self { - let expected_hrefs = hrefs.into_iter().collect::>(); - let hrefs = self - .find_keys("D:multistatus.D:response.D:href") - .collect::>(); - if expected_hrefs != hrefs { - self.dump_response(); - - println!("\nMissing: {:?}", expected_hrefs.difference(&hrefs)); - println!("\nExtra: {:?}", hrefs.difference(&expected_hrefs)); - - panic!( - "Hierarchy mismatch: expected {} items, received {} items", - expected_hrefs.len(), - hrefs.len() - ); - } - self - } - - fn dump_response(&self) { - eprintln!("-------------------------------------"); - eprintln!("Status: {}", self.status); - eprintln!("Headers:"); - for (key, value) in self.headers.iter() { - eprintln!(" {}: {:?}", key, value); - } - if !self.xml.is_empty() { - eprintln!("XML: {}", xml_pretty_print(self.body.as_ref().unwrap())); - - for (key, value) in self.xml.iter() { - eprintln!("{} -> {:?}", key, value); - } - } else { - eprintln!("Body: {:?}", self.body); - } - } - - fn find_keys(&self, name: &str) -> impl Iterator { - self.xml - .iter() - .filter(move |(key, _)| name == key) - .map(|(_, value)| value.as_str()) - } - - pub fn value(&self, name: &str) -> &str { - self.find_keys(name).next().unwrap_or_else(|| { - self.dump_response(); - panic!("Key {name} not found.") - }) - } - - // Poor man's XPath - pub fn with_value(self, query: &str, expect: impl AsRef) -> Self { - let expect = expect.as_ref(); - if let Some(value) = self.find_keys(query).next() { - if value != expect { - self.dump_response(); - panic!("Expected {query} = {expect:?} but got {value:?}"); - } - } else { - self.dump_response(); - panic!("Key {query} not found."); - } - self - } - - pub fn with_any_value<'x>( - self, - query: &str, - expect: impl IntoIterator, - ) -> Self { - let expect = expect.into_iter().collect::>(); - if let Some(value) = self.find_keys(query).next() { - if !expect.contains(value) { - self.dump_response(); - panic!("Expected {query} = {expect:?} but got {value:?}"); - } - } else { - self.dump_response(); - panic!("Key {query} not found."); - } - self - } - - pub fn with_values(self, query: &str, expect: I) -> Self - where - I: IntoIterator, - T: AsRef, - { - let expect_owned: Vec = expect.into_iter().collect(); - let expect = expect_owned.iter().map(|s| s.as_ref()).collect::>(); - let found = self.find_keys(query).collect::>(); - if expect != found { - self.dump_response(); - panic!("Expected {query} = {expect:?} but got {found:?}"); - } - self - } - - pub fn with_failed_precondition(self, precondition: &str, value: &str) -> Self { - let error = format!("D:error.{precondition}"); - if self.find_keys(&error).next().is_none_or(|v| v != value) { - self.dump_response(); - panic!("Precondition {precondition} did not match."); - } - self - } -} - pub trait DavResourcesTest { fn items(&self) -> Vec; } @@ -732,104 +292,6 @@ impl DavResourcesTest for DavResources { } } -fn flatten_xml(xml: &str) -> Vec<(String, String)> { - let mut reader = Reader::from_str(xml); - - let mut path: Vec = Vec::new(); - let mut result: Vec<(String, String)> = Vec::new(); - let mut buf = Vec::new(); - let mut text_content: Option = None; - - loop { - match reader.read_event_into(&mut buf).unwrap() { - Event::Start(ref e) => { - let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); - path.push(name); - let base_path = path.join("."); - for attr in e.attributes() { - let attr = attr.unwrap(); - let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); - let value = attr.unescape_value().unwrap(); - let value_str = value.trim().to_string(); - - result.push((format!("{}.[{}]", base_path, key), value_str)); - } - text_content = None; - } - Event::Empty(ref e) => { - let name = str::from_utf8(e.name().as_ref()).unwrap().to_string(); - let base_path = format!("{}.{}", path.join("."), name); - let mut has_attrs = false; - - for attr in e.attributes() { - let attr = attr.unwrap(); - let key = str::from_utf8(attr.key.as_ref()).unwrap().to_string(); - let value = attr.unescape_value().unwrap(); - let value_str = value.trim().to_string(); - has_attrs = true; - result.push((format!("{}.[{}]", base_path, key), value_str)); - } - - if !has_attrs { - result.push((base_path, "".to_string())); - } - } - Event::Text(e) => { - let text = e.xml_content().unwrap(); - let trimmed = text.trim(); - if !trimmed.is_empty() { - if let Some(text_content) = text_content.as_mut() { - text_content.push_str(trimmed); - } else { - text_content = Some(trimmed.to_string()); - } - } - } - Event::GeneralRef(entity) => { - let value: Cow = match entity.as_ref() { - b"lt" => "<".into(), - b"gt" => ">".into(), - b"amp" => "&".into(), - b"apos" => "'".into(), - b"quot" => "\"".into(), - _ => { - if let Ok(Some(gr)) = entity.resolve_char_ref() { - gr.to_string().into() - } else { - std::str::from_utf8(entity.as_ref()) - .unwrap_or_default() - .into() - } - } - }; - - if let Some(text_content) = text_content.as_mut() { - text_content.push_str(value.as_ref()); - } else { - text_content = Some(value.into_owned()); - } - } - Event::CData(e) => { - text_content = Some(std::str::from_utf8(e.as_ref()).unwrap().to_string()); - } - Event::End(_) => { - if let Some(text) = text_content.take() { - result.push((path.join("."), text)); - } - - if !path.is_empty() { - path.pop(); - } - } - Event::Eof => break, - _ => {} - } - buf.clear(); - } - - result -} - pub const TEST_VCARD_1: &str = r#"BEGIN:VCARD VERSION:4.0 UID:18F098B5-7383-4FD6-B482-48F2181D73AA @@ -927,126 +389,6 @@ END:VTIMEZONE END:VCALENDAR "#; -pub trait GenerateTestDavResource { - fn generate(&self) -> String; -} - -impl GenerateTestDavResource for DavResourceName { - fn generate(&self) -> String { - match self { - DavResourceName::Card => generate_random_vcard(), - DavResourceName::Cal => generate_random_ical(), - DavResourceName::File => generate_random_content(100, 200), - _ => unreachable!(), - } - } -} - -fn generate_random_vcard() -> String { - r#"BEGIN:VCARD -VERSION:4.0 -UID:$UID -FN:$NAME -END:VCARD -"# - .replace("$UID", &generate_random_name(8)) - .replace("$NAME", &generate_random_name(10)) - .replace('\n', "\r\n") -} - -fn generate_random_ical() -> String { - r#"BEGIN:VCALENDAR -VERSION:2.0 -BEGIN:VEVENT -UID:$UID -SUMMARY:$SUMMARY -DESCRIPTION:$DESCRIPTION -END:VEVENT -END:VCALENDAR -"# - .replace("$UID", &generate_random_name(8)) - .replace("$SUMMARY", &generate_random_name(10)) - .replace("$DESCRIPTION", &generate_random_name(20)) - .replace('\n', "\r\n") -} - -fn generate_random_content(min_chars: usize, max_chars: usize) -> String { - let mut rng = rng(); - let length = rng.random_range(min_chars..=max_chars); - - let words = [ - "lorem", - "ipsum", - "dolor", - "sit", - "amet", - "consectetur", - "adipiscing", - "elit", - "sed", - "do", - "eiusmod", - "tempor", - "incididunt", - "ut", - "labore", - "et", - "dolore", - "magna", - "aliqua", - "ut", - "enim", - "ad", - "minim", - "veniam", - "quis", - "nostrud", - "exercitation", - "ullamco", - "laboris", - "nisi", - "ut", - "aliquip", - "ex", - "ea", - "commodo", - "consequat", - ]; - - let mut content = String::with_capacity(length); - - while content.len() < length { - let word_idx = rng.random_range(0..words.len()); - if !content.is_empty() { - content.push(' '); - } - if rng.random_ratio(1, 10) { - content.push('.'); - let word = words[word_idx]; - let mut chars = word.chars(); - if let Some(first_char) = chars.next() { - content.push_str(&first_char.to_uppercase().to_string()); - content.push_str(chars.as_str()); - } - } else { - content.push_str(words[word_idx]); - } - } - - if !content.ends_with('.') { - content.push('.'); - } - - content -} - -fn generate_random_name(length: usize) -> String { - let mut rng = rng(); - (0..length) - .map(|_| rng.sample(Alphanumeric) as char) - .collect() -} - impl WebDavTest { pub async fn fetch_email(&self, account_id: u32, document_id: u32) -> Vec { let metadata_ = self diff --git a/tests/src/webdav/multiget.rs b/tests/src/webdav/multiget.rs index ac816a3a..cce0e392 100644 --- a/tests/src/webdav/multiget.rs +++ b/tests/src/webdav/multiget.rs @@ -10,26 +10,6 @@ use dav_proto::schema::property::{CalDavProperty, CardDavProperty, DavProperty, use groupware::DavResourceName; use hyper::StatusCode; -const MULTIGET_CALENDAR: &str = r#" - - - - - - $PATH - -"#; -const MULTIGET_ADDRESSBOOK: &str = r#" - - - - - - $PATH - -"#; - pub async fn test(test: &WebDavTest) { let client = test.client("john"); @@ -90,33 +70,3 @@ pub async fn test(test: &WebDavTest) { client.delete_default_containers().await; test.assert_is_empty().await; } - -impl DummyWebDavClient { - pub async fn multiget_calendar(&self, path: &str, uris: &[&str]) -> DavMultiStatus { - let mut paths = String::new(); - for uri in uris { - paths.push_str(&format!("{}", uri)); - } - - self.request("REPORT", path, &MULTIGET_CALENDAR.replace("$PATH", &paths)) - .await - .with_status(StatusCode::MULTI_STATUS) - .into_propfind_response(None) - } - - pub async fn multiget_addressbook(&self, path: &str, uris: &[&str]) -> DavMultiStatus { - let mut paths = String::new(); - for uri in uris { - paths.push_str(&format!("{}", uri)); - } - - self.request( - "REPORT", - path, - &MULTIGET_ADDRESSBOOK.replace("$PATH", &paths), - ) - .await - .with_status(StatusCode::MULTI_STATUS) - .into_propfind_response(None) - } -} diff --git a/tests/src/webdav/prop.rs b/tests/src/webdav/prop.rs index 567a3f0c..88195355 100644 --- a/tests/src/webdav/prop.rs +++ b/tests/src/webdav/prop.rs @@ -706,498 +706,6 @@ pub async fn test(test: &WebDavTest, assisted_discovery: bool) { test.assert_is_empty().await; } -#[derive(Debug)] -pub struct DavMultiStatus { - pub response: DavResponse, - pub hrefs: AHashMap, -} - -#[derive(Debug, serde::Serialize)] -pub struct DavItem { - #[serde(serialize_with = "serialize_status_code")] - pub status: StatusCode, - pub values: AHashMap>, - pub error: Vec, - pub description: Option, -} - -#[derive(Debug, serde::Serialize)] -pub struct DavProperties { - #[serde(skip)] - status: StatusCode, - props: Vec, -} - -impl DavMultiStatus { - pub fn properties(&self, href: &str) -> DavPropertyResult<'_> { - DavPropertyResult { - response: &self.response, - properties: self.hrefs.get(href).unwrap_or_else(|| { - self.response.dump_response(); - panic!( - "No properties found for href: {href} in {}", - serde_json::to_string_pretty(&self.hrefs).unwrap() - ) - }), - } - } - - pub fn with_hrefs<'x>(&self, expect_hrefs: impl IntoIterator) -> &Self { - let expect_hrefs: AHashSet<_> = expect_hrefs.into_iter().collect(); - let hrefs: AHashSet<_> = self.hrefs.keys().map(|s| s.as_str()).collect(); - if hrefs != expect_hrefs { - self.response.dump_response(); - panic!("Expected hrefs {expect_hrefs:?}, but got {hrefs:?}",); - } - self - } -} - -pub struct DavPropertyResult<'x> { - pub response: &'x DavResponse, - pub properties: &'x DavProperties, -} - -pub struct DavQueryResult<'x> { - pub response: &'x DavResponse, - pub prop: &'x DavItem, - pub values: &'x [String], -} - -impl DavPropertyResult<'_> { - pub fn get(&self, name: impl AsRef) -> DavQueryResult<'_> { - let name = name.as_ref(); - self.properties - .props - .iter() - .find_map(|prop| { - prop.values.get(name).map(|values| DavQueryResult { - response: self.response, - prop, - values, - }) - }) - .unwrap_or_else(|| { - self.response.dump_response(); - panic!( - "No property found for name: {name} in {}", - serde_json::to_string_pretty(&self.properties.props).unwrap() - ) - }) - } - - pub fn with_status(&self, status: StatusCode) -> &Self { - if self.properties.status != status { - self.response.dump_response(); - panic!( - "Expected status {status}, but got {}", - self.properties.status - ); - } - self - } - - pub fn is_defined(&self, name: impl AsRef) -> &Self { - if self - .properties - .props - .iter() - .any(|prop| prop.values.contains_key(name.as_ref())) - { - self - } else { - self.response.dump_response(); - panic!("Expected property {} to be defined", name.as_ref()); - } - } - - pub fn is_undefined(&self, name: impl AsRef) -> &Self { - if self - .properties - .props - .iter() - .any(|prop| prop.values.contains_key(name.as_ref())) - { - self.response.dump_response(); - panic!("Expected property {} to be undefined", name.as_ref()); - } - self - } - - pub fn calendar_data(&self) -> DavQueryResult<'_> { - self.get(DavProperty::CalDav(CalDavProperty::CalendarData( - Default::default(), - ))) - } -} - -impl<'x> DavQueryResult<'x> { - pub fn with_values(&self, expected_values: impl IntoIterator) -> &Self { - let expected_values = AHashSet::from_iter(expected_values); - let values = self - .values - .iter() - .map(|s| s.as_str()) - .collect::>(); - - if values != expected_values { - self.response.dump_response(); - assert_eq!(values, expected_values,); - } - self - } - - pub fn with_some_values(&self, expected_values: impl IntoIterator) -> &Self { - let values = self - .values - .iter() - .map(|s| s.as_str()) - .collect::>(); - - for expected_value in expected_values { - if !values.contains(expected_value) { - self.response.dump_response(); - panic!("Expected at least one of {expected_value:?} values, but got {values:?}",); - } - } - - self - } - - pub fn with_any_values(&self, expected_values: impl IntoIterator) -> &Self { - let values = self - .values - .iter() - .map(|s| s.as_str()) - .collect::>(); - let expected_values = AHashSet::from_iter(expected_values); - - if values.is_disjoint(&expected_values) { - self.response.dump_response(); - panic!("Expected at least one of {expected_values:?} values, but got {values:?}",); - } - - self - } - - pub fn without_values(&self, expected_values: impl IntoIterator) -> &Self { - let expected_values = AHashSet::from_iter(expected_values); - let values = self - .values - .iter() - .map(|s| s.as_str()) - .collect::>(); - - if !expected_values.is_disjoint(&values) { - self.response.dump_response(); - panic!("Expected no {expected_values:?} values, but got {values:?}",); - } - self - } - - pub fn is_not_empty(&self) -> &Self { - if self.values.is_empty() || self.values.iter().all(|s| s.is_empty()) { - self.response.dump_response(); - panic!("Expected non-empty values, but got {:?}", self.values); - } - self - } - - pub fn value(&self) -> &str { - if let Some(value) = self.values.iter().find(|s| !s.is_empty()) { - value - } else { - self.response.dump_response(); - panic!("Expected a value, but got {:?}", self.values); - } - } - - pub fn with_status(&self, status: StatusCode) -> &Self { - if self.prop.status != status { - self.response.dump_response(); - panic!("Expected status {status}, but got {}", self.prop.status); - } - self - } - - pub fn with_description(&self, description: &str) -> &Self { - if self.prop.description.as_deref() != Some(description) { - self.response.dump_response(); - panic!( - "Expected description {description}, but got {:?}", - self.prop.description - ); - } - self - } - pub fn with_error(&self, error: &str) -> &Self { - if !self.prop.error.contains(&error.to_string()) { - self.response.dump_response(); - panic!("Expected error {error}, but got {:?}", self.prop.error); - } - self - } -} - -impl DavResponse { - pub fn into_propfind_response(mut self, prop_prefix: Option<&str>) -> DavMultiStatus { - if let Some(prop_prefix) = prop_prefix { - for (key, _) in self.xml.iter_mut() { - if let Some(suffix) = key.strip_prefix(prop_prefix) { - *key = format!("D:multistatus.D:response{suffix}"); - } - } - self.xml.push(( - "D:multistatus.D:response.D:href".to_string(), - "".to_string(), - )); - } - - let mut result = DavMultiStatus { - response: self, - hrefs: AHashMap::new(), - }; - let mut href = None; - let mut href_status = StatusCode::OK; - let mut props = Vec::new(); - let mut prop = DavItem::default(); - - for (key, value) in &result.response.xml { - match key.as_str() { - "D:multistatus.D:response.D:href" => { - if let Some(href) = href.take() { - if !prop.is_empty() { - props.push(std::mem::take(&mut prop)); - } - result.hrefs.insert( - href, - DavProperties { - status: href_status, - props: std::mem::take(&mut props), - }, - ); - href_status = StatusCode::OK; - } - href = Some(value.to_string()); - } - "D:multistatus.D:response.D:status" => { - href_status = value - .split_ascii_whitespace() - .nth(1) - .unwrap_or_default() - .parse() - .unwrap(); - } - "D:multistatus.D:response.D:propstat.D:status" => { - prop.status = value - .split_ascii_whitespace() - .nth(1) - .unwrap_or_default() - .parse() - .unwrap(); - } - "D:multistatus.D:response.D:propstat.D:responsedescription" => { - prop.description = Some(value.to_string()); - } - _ => { - if let Some(prop_name) = - key.strip_prefix("D:multistatus.D:response.D:propstat.D:prop.") - { - if prop.status != StatusCode::PROXY_AUTHENTICATION_REQUIRED { - props.push(std::mem::take(&mut prop)); - } - - let (prop_name, prop_value) = - if let Some((prop_name, prop_sub_name)) = prop_name.split_once('.') { - if value.is_empty() { - (prop_name, prop_sub_name.to_string()) - } else { - (prop_name, format!("{}:{}", prop_sub_name, value)) - } - } else { - (prop_name, value.to_string()) - }; - prop.values - .entry(prop_name.to_string()) - .or_default() - .push(prop_value); - } - } - } - } - - if let Some(href) = href.take() { - if !prop.is_empty() { - props.push(prop); - } - result.hrefs.insert( - href, - DavProperties { - status: href_status, - props, - }, - ); - } - - result - } -} - -impl DummyWebDavClient { - pub async fn patch_and_check( - &self, - path: &str, - properties: impl IntoIterator, - ) where - T: AsRef + Clone, - { - let mut expect_set = Vec::new(); - let mut expect_remove = Vec::new(); - - for (key, value) in properties { - if !value.is_empty() { - expect_set.push((key, value)); - } else { - expect_remove.push(key); - } - } - - let response = self - .proppatch( - path, - expect_set.iter().cloned(), - expect_remove.iter().cloned(), - [], - ) - .await - .with_status(StatusCode::MULTI_STATUS) - .into_propfind_response(None); - let patch_prop = response.properties(path); - for (key, _) in &expect_set { - patch_prop.get(key.as_ref()).with_status(StatusCode::OK); - } - for key in &expect_remove { - patch_prop - .get(key.as_ref()) - .with_status(StatusCode::NO_CONTENT); - } - - let response = self - .propfind( - path, - expect_set - .iter() - .map(|(k, _)| k) - .chain(expect_remove.iter()), - ) - .await; - let prop = response.properties(path); - - for (key, value) in expect_set { - prop.get(key.as_ref()) - .with_values([value]) - .with_status(StatusCode::OK); - } - - for key in expect_remove { - prop.get(key.as_ref()).with_status(StatusCode::NOT_FOUND); - } - } - - pub async fn propfind(&self, path: &str, properties: I) -> DavMultiStatus - where - I: IntoIterator, - T: AsRef, - { - self.propfind_with_headers(path, properties, []).await - } - - pub async fn propfind_with_headers( - &self, - path: &str, - properties: I, - headers: impl IntoIterator, - ) -> DavMultiStatus - where - I: IntoIterator, - T: AsRef, - { - let mut request = concat!( - "", - "", - "" - ) - .to_string(); - - for property in properties { - request.push_str(&format!("<{}/>", property.as_ref())); - } - - request.push_str(""); - - self.request_with_headers("PROPFIND", path, headers, &request) - .await - .with_status(StatusCode::MULTI_STATUS) - .into_propfind_response(None) - } - - pub async fn proppatch( - &self, - path: &str, - set: impl IntoIterator, - clear: impl IntoIterator, - headers: impl IntoIterator, - ) -> DavResponse - where - T: AsRef, - { - let mut request = concat!( - "", - "", - "" - ) - .to_string(); - - for property in clear { - request.push_str(&format!("<{}/>", property.as_ref())); - } - - request.push_str(""); - - for (key, value) in set { - let key = key.as_ref(); - request.push_str(&format!("<{key}>{value}")); - } - - request.push_str(""); - - self.request_with_headers("PROPPATCH", path, headers, &request) - .await - } -} - -impl DavItem { - pub fn is_empty(&self) -> bool { - self.values.is_empty() - && self.status == StatusCode::PROXY_AUTHENTICATION_REQUIRED - && self.error.is_empty() - && self.description.is_none() - } -} - -impl Default for DavItem { - fn default() -> Self { - DavItem { - status: StatusCode::PROXY_AUTHENTICATION_REQUIRED, - values: AHashMap::new(), - error: Vec::new(), - description: None, - } - } -} - const EXPAND_REPORT_QUERY: &str = r#" (status_code: &StatusCode, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str(&status_code.to_string()) -} diff --git a/tests/src/webdav/sync.rs b/tests/src/webdav/sync.rs index eb539416..7f4c6457 100644 --- a/tests/src/webdav/sync.rs +++ b/tests/src/webdav/sync.rs @@ -290,47 +290,3 @@ pub async fn test(test: &WebDavTest) { client.delete_default_containers().await; test.assert_is_empty().await; } - -impl DummyWebDavClient { - pub async fn sync_collection( - &self, - path: &str, - sync_token: &str, - depth: Depth, - limit: Option, - properties: impl IntoIterator, - ) -> DavResponse { - let mut request = concat!( - "", - "", - "" - ) - .to_string(); - - for property in properties { - request.push_str(&format!("<{property}/>")); - } - - request.push_str(""); - request.push_str(sync_token); - request.push_str(""); - request.push_str(match depth { - Depth::One => "1", - Depth::Infinity => "infinite", - _ => "0", - }); - request.push_str(""); - - if let Some(limit) = limit { - request.push_str(""); - request.push_str(&limit.to_string()); - request.push_str(""); - } - - request.push_str(""); - - self.request("REPORT", path, &request) - .await - .with_status(StatusCode::MULTI_STATUS) - } -}