From 71ae50f9c976624e5e6efc88540cbe3cddbd5e21 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 14 Oct 2025 12:34:18 +0200 Subject: [PATCH] JMAP for Contacts passing tests --- Cargo.lock | 1 + crates/common/src/auth/access_token.rs | 8 +- crates/dav/src/common/acl.rs | 8 +- crates/email/src/mailbox/destroy.rs | 4 +- crates/groupware/Cargo.toml | 1 + crates/groupware/src/calendar/index.rs | 35 +- crates/groupware/src/contact/index.rs | 15 +- crates/imap-proto/src/protocol/acl.rs | 33 +- crates/imap/src/op/acl.rs | 4 +- crates/jmap-proto/src/method/parse.rs | 3 +- crates/jmap-proto/src/object/addressbook.rs | 19 +- crates/jmap-proto/src/object/calendar.rs | 18 +- crates/jmap-proto/src/object/contact.rs | 4 +- crates/jmap-proto/src/object/file_node.rs | 13 +- crates/jmap-proto/src/object/mailbox.rs | 17 +- crates/jmap-proto/src/object/mod.rs | 5 - .../src/object/share_notification.rs | 8 +- crates/jmap-proto/src/references/eval.rs | 15 +- crates/jmap-proto/src/references/resolve.rs | 22 +- crates/jmap-proto/src/request/parser.rs | 41 +- crates/jmap-proto/src/response/mod.rs | 1 + crates/jmap/src/addressbook/set.rs | 211 ++- crates/jmap/src/api/acl.rs | 15 +- crates/jmap/src/calendar/set.rs | 209 ++- crates/jmap/src/calendar_event/copy.rs | 6 +- crates/jmap/src/calendar_event/get.rs | 2 +- crates/jmap/src/calendar_event/query.rs | 10 +- crates/jmap/src/calendar_event/set.rs | 9 +- crates/jmap/src/changes/get.rs | 24 +- crates/jmap/src/contact/copy.rs | 6 +- crates/jmap/src/contact/get.rs | 2 +- crates/jmap/src/contact/query.rs | 15 +- crates/jmap/src/contact/set.rs | 6 +- crates/jmap/src/email/copy.rs | 4 +- crates/jmap/src/file/set.rs | 3 +- crates/jmap/src/mailbox/set.rs | 4 +- crates/jmap/src/share_notification/get.rs | 102 +- crates/types/src/acl.rs | 6 +- crates/utils/src/snowflake.rs | 2 +- tests/src/cluster/stress.rs | 4 +- tests/src/jmap/auth/limits.rs | 4 +- tests/src/jmap/auth/oauth.rs | 4 +- tests/src/jmap/auth/permissions.rs | 4 +- tests/src/jmap/auth/quota.rs | 4 +- tests/src/jmap/calendar/acl.rs | 14 + tests/src/jmap/calendar/calendars.rs | 14 + tests/src/jmap/calendar/event.rs | 16 + tests/src/jmap/calendar/identity.rs | 16 + tests/src/jmap/calendar/mod.rs | 7 +- tests/src/jmap/calendar/notification.rs | 14 + tests/src/jmap/calendar/principal.rs | 14 + tests/src/jmap/contacts/acl.rs | 603 ++++++++ tests/src/jmap/contacts/addressbook.rs | 212 +++ tests/src/jmap/contacts/contact.rs | 1266 +++++++++++++++++ tests/src/jmap/contacts/mod.rs | 4 +- tests/src/jmap/core/blob.rs | 4 +- tests/src/jmap/core/event_source.rs | 5 +- tests/src/jmap/core/push_subscription.rs | 8 +- tests/src/jmap/core/websocket.rs | 5 +- tests/src/jmap/files/acl.rs | 14 + tests/src/jmap/files/mod.rs | 3 + tests/src/jmap/files/node.rs | 14 + tests/src/jmap/mail/acl.rs | 4 +- tests/src/jmap/mail/changes.rs | 4 +- tests/src/jmap/mail/copy.rs | 5 +- tests/src/jmap/mail/delivery.rs | 11 +- tests/src/jmap/mail/get.rs | 5 +- tests/src/jmap/mail/mailbox.rs | 5 +- tests/src/jmap/mail/parse.rs | 5 +- tests/src/jmap/mail/query.rs | 4 +- tests/src/jmap/mail/query_changes.rs | 4 +- tests/src/jmap/mail/search_snippet.rs | 4 +- tests/src/jmap/mail/set.rs | 7 +- tests/src/jmap/mail/sieve_script.rs | 4 +- tests/src/jmap/mail/submission.rs | 4 +- tests/src/jmap/mail/thread_get.rs | 5 +- tests/src/jmap/mail/thread_merge.rs | 7 +- tests/src/jmap/mail/vacation_response.rs | 4 +- tests/src/jmap/mod.rs | 455 +++++- tests/src/jmap/server/purge.rs | 4 +- tests/src/webdav/mod.rs | 11 +- 81 files changed, 3255 insertions(+), 461 deletions(-) create mode 100644 tests/src/jmap/calendar/acl.rs create mode 100644 tests/src/jmap/calendar/calendars.rs create mode 100644 tests/src/jmap/calendar/event.rs create mode 100644 tests/src/jmap/calendar/identity.rs create mode 100644 tests/src/jmap/calendar/notification.rs create mode 100644 tests/src/jmap/calendar/principal.rs create mode 100644 tests/src/jmap/contacts/acl.rs create mode 100644 tests/src/jmap/contacts/addressbook.rs create mode 100644 tests/src/jmap/contacts/contact.rs create mode 100644 tests/src/jmap/files/acl.rs create mode 100644 tests/src/jmap/files/node.rs diff --git a/Cargo.lock b/Cargo.lock index 1837fe3e..ba09fa28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2855,6 +2855,7 @@ dependencies = [ "compact_str", "directory", "hashify", + "nlp", "percent-encoding", "rkyv", "store", diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 1c1142f8..b8819612 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -172,13 +172,13 @@ impl Server { } let mut collections: Bitmap = Bitmap::new(); - if acl.contains(Acl::Read) || acl.contains(Acl::Administer) { + if acl.contains(Acl::Read) { collections.insert(collection); } - if collection == Collection::Mailbox - && (acl.contains(Acl::ReadItems) || acl.contains(Acl::Administer)) + if acl.contains(Acl::ReadItems) + && let Some(child_col) = collection.child_collection() { - collections.insert(Collection::Email); + collections.insert(child_col); } if !collections.is_empty() { diff --git a/crates/dav/src/common/acl.rs b/crates/dav/src/common/acl.rs index aeb809a8..981e0753 100644 --- a/crates/dav/src/common/acl.rs +++ b/crates/dav/src/common/acl.rs @@ -119,7 +119,7 @@ impl DavAclHandler for Server { // Validate ACL let acls = container.acls().unwrap(); if !access_token.is_member(account_id) - && !acls.effective_acl(access_token).contains(Acl::Administer) + && !acls.effective_acl(access_token).contains(Acl::Share) { return Err(DavError::Code(StatusCode::FORBIDDEN)); } @@ -337,7 +337,7 @@ impl DavAclHandler for Server { } Privilege::ReadAcl => {} Privilege::WriteAcl => { - acls.insert(Acl::Administer); + acls.insert(Acl::Share); } Privilege::ReadFreeBusy | Privilege::ScheduleQueryFreeBusy @@ -444,7 +444,7 @@ impl DavAclHandler for Server { ) -> crate::Result> { let mut aces = Vec::with_capacity(grants.len()); if access_token.is_member(account_id) - || grants.effective_acl(access_token).contains(Acl::Administer) + || grants.effective_acl(access_token).contains(Acl::Share) { for grant in grants.iter() { let grant_account_id = u32::from(grant.account_id); @@ -554,7 +554,7 @@ pub(crate) fn current_user_privilege_set(acl_bitmap: Bitmap) -> Vec { acls.insert(Privilege::Write); } - Acl::Administer => { + Acl::Share => { acls.insert(Privilege::ReadAcl); acls.insert(Privilege::WriteAcl); } diff --git a/crates/email/src/mailbox/destroy.rs b/crates/email/src/mailbox/destroy.rs index a0fcf0a2..14368255 100644 --- a/crates/email/src/mailbox/destroy.rs +++ b/crates/email/src/mailbox/destroy.rs @@ -149,9 +149,7 @@ impl MailboxDestroy for Server { // Validate ACLs if access_token.is_shared(account_id) { let acl = mailbox.inner.acls.effective_acl(access_token); - if !acl.contains(Acl::Administer) - && (!acl.contains(Acl::Delete) - || (remove_emails && !acl.contains(Acl::RemoveItems))) + if !acl.contains(Acl::Delete) || (remove_emails && !acl.contains(Acl::RemoveItems)) { return Ok(Err(MailboxDestroyError::Forbidden)); } diff --git a/crates/groupware/Cargo.toml b/crates/groupware/Cargo.toml index 4df01265..b5e658a8 100644 --- a/crates/groupware/Cargo.toml +++ b/crates/groupware/Cargo.toml @@ -10,6 +10,7 @@ store = { path = "../store" } common = { path = "../common" } types = { path = "../types" } trc = { path = "../trc" } +nlp = { path = "../nlp" } directory = { path = "../directory" } calcard = { path = "/Users/me/code/calcard", features = ["rkyv"] } hashify = "0.2" diff --git a/crates/groupware/src/calendar/index.rs b/crates/groupware/src/calendar/index.rs index 416f0bd9..7c32232a 100644 --- a/crates/groupware/src/calendar/index.rs +++ b/crates/groupware/src/calendar/index.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::collections::HashSet; - use super::{ ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarPreferences, ArchivedDefaultAlert, ArchivedTimezone, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone, @@ -21,9 +19,10 @@ use calcard::icalendar::{ use common::storage::index::{ IndexItem, IndexValue, IndexableAndSerializableObject, IndexableObject, }; +use nlp::tokenizers::word::WordTokenizer; +use std::collections::HashSet; use store::backend::MAX_TOKEN_LENGTH; use types::{acl::AclGrant, collection::SyncCollection, field::CalendarField}; -use utils::sanitize_email; impl IndexableObject for Calendar { fn index_values(&self) -> impl Iterator> { @@ -97,13 +96,6 @@ impl IndexableObject for CalendarEvent { field: CalendarField::Text.into(), value: self .text() - .filter_map(|v| { - if let Some(email) = v.strip_prefix("mailto:") { - sanitize_email(email) - } else { - Some(v.to_lowercase()) - } - }) .map(Into::into) .collect::>() .into_iter() @@ -148,13 +140,6 @@ impl IndexableObject for &ArchivedCalendarEvent { field: CalendarField::Text.into(), value: self .text() - .filter_map(|v| { - if let Some(email) = v.strip_prefix("mailto:") { - sanitize_email(email) - } else { - Some(v.to_lowercase()) - } - }) .map(Into::into) .collect::>() .into_iter() @@ -308,7 +293,7 @@ impl ArchivedDefaultAlert { } impl CalendarEvent { - pub fn text(&self) -> impl Iterator { + pub fn text(&self) -> impl Iterator { self.data .event .components @@ -342,13 +327,15 @@ impl CalendarEvent { _ => None, })) }) - .flat_map(str::split_whitespace) - .filter(|s| s.len() < MAX_TOKEN_LENGTH) + .flat_map(|v| { + WordTokenizer::new(v.strip_prefix("mailto:").unwrap_or(v), MAX_TOKEN_LENGTH) + }) + .map(|t| t.word.into_owned()) } } impl ArchivedCalendarEvent { - pub fn text(&self) -> impl Iterator { + pub fn text(&self) -> impl Iterator { self.data .event .components @@ -382,7 +369,9 @@ impl ArchivedCalendarEvent { _ => None, })) }) - .flat_map(str::split_whitespace) - .filter(|s| s.len() < MAX_TOKEN_LENGTH) + .flat_map(|v| { + WordTokenizer::new(v.strip_prefix("mailto:").unwrap_or(v), MAX_TOKEN_LENGTH) + }) + .map(|t| t.word.into_owned()) } } diff --git a/crates/groupware/src/contact/index.rs b/crates/groupware/src/contact/index.rs index d07de352..a03d5e4e 100644 --- a/crates/groupware/src/contact/index.rs +++ b/crates/groupware/src/contact/index.rs @@ -9,6 +9,7 @@ use calcard::vcard::{ArchivedVCardProperty, VCardProperty}; use common::storage::index::{ IndexItem, IndexValue, IndexableAndSerializableObject, IndexableObject, }; +use nlp::tokenizers::word::WordTokenizer; use std::collections::HashSet; use store::backend::MAX_TOKEN_LENGTH; use types::{acl::AclGrant, collection::SyncCollection, field::ContactField}; @@ -96,7 +97,6 @@ impl IndexableObject for ContactCard { field: ContactField::Text.into(), value: self .text() - .map(str::to_lowercase) .map(Into::into) .collect::>() .into_iter() @@ -145,7 +145,6 @@ impl IndexableObject for &ArchivedContactCard { field: ContactField::Text.into(), value: self .text() - .map(str::to_lowercase) .map(Into::into) .collect::>() .into_iter() @@ -182,7 +181,7 @@ impl IndexableAndSerializableObject for ContactCard { } impl ContactCard { - pub fn text(&self) -> impl Iterator { + pub fn text(&self) -> impl Iterator { self.card .entries .iter() @@ -199,8 +198,8 @@ impl ContactCard { ) }) .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) - .flat_map(str::split_whitespace) - .filter(|s| s.len() < MAX_TOKEN_LENGTH) + .flat_map(|v| WordTokenizer::new(v, MAX_TOKEN_LENGTH)) + .map(|t| t.word.into_owned()) } pub fn emails(&self) -> impl Iterator { @@ -213,7 +212,7 @@ impl ContactCard { } impl ArchivedContactCard { - pub fn text(&self) -> impl Iterator { + pub fn text(&self) -> impl Iterator { self.card .entries .iter() @@ -230,8 +229,8 @@ impl ArchivedContactCard { ) }) .flat_map(|e| e.values.iter().filter_map(|v| v.as_text())) - .flat_map(str::split_whitespace) - .filter(|s| s.len() < MAX_TOKEN_LENGTH) + .flat_map(|v| WordTokenizer::new(v, MAX_TOKEN_LENGTH)) + .map(|t| t.word.into_owned()) } pub fn emails(&self) -> impl Iterator { diff --git a/crates/imap-proto/src/protocol/acl.rs b/crates/imap-proto/src/protocol/acl.rs index 90fac3ab..978755b9 100644 --- a/crates/imap-proto/src/protocol/acl.rs +++ b/crates/imap-proto/src/protocol/acl.rs @@ -160,37 +160,6 @@ impl MyRightsResponse { } impl Rights { - /*pub fn from_acl(value: ACL) -> (Self, Option) { - match value { - ACL::Read => (Rights::Lookup, None), - ACL::Modify => (Rights::CreateMailbox, None), - ACL::Delete => (Rights::DeleteMailbox, None), - ACL::ReadItems => (Rights::Read, None), - ACL::AddItems => (Rights::Insert, None), - ACL::ModifyItems => (Rights::Write, Rights::Seen.into()), - ACL::RemoveItems => (Rights::DeleteMessages, Rights::Expunge.into()), - ACL::CreateChild => (Rights::CreateMailbox, None), - ACL::Administer => (Rights::Administer, None), - ACL::Submit => (Rights::Post, None), - } - } - - pub fn into_acl(self) -> ACL { - match self { - Rights::Lookup => ACL::Read, - Rights::Read => ACL::ReadItems, - Rights::Seen => ACL::ModifyItems, - Rights::Write => ACL::ModifyItems, - Rights::Insert => ACL::AddItems, - Rights::Post => ACL::Submit, - Rights::CreateMailbox => ACL::CreateChild, - Rights::DeleteMailbox => ACL::Delete, - Rights::DeleteMessages => ACL::RemoveItems, - Rights::Expunge => ACL::RemoveItems, - Rights::Administer => ACL::Administer, - } - }*/ - pub fn to_char(&self) -> u8 { match self { Rights::Lookup => b'l', @@ -239,7 +208,7 @@ impl From for Acl { Rights::DeleteMailbox => Acl::Delete, Rights::DeleteMessages => Acl::RemoveItems, Rights::Expunge => Acl::RemoveItems, - Rights::Administer => Acl::Administer, + Rights::Administer => Acl::Share, } } } diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index f0be8da7..2fab8750 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -120,7 +120,7 @@ impl Session { Acl::CreateChild => { rights.push(Rights::CreateMailbox); } - Acl::Administer => { + Acl::Share => { rights.push(Rights::Administer); } Acl::Submit => { @@ -472,7 +472,7 @@ impl SessionData { .caused_by(trc::location!())? .acls .effective_acl(&access_token) - .contains(Acl::Administer) + .contains(Acl::Share) { Ok((mailbox, values, access_token)) } else { diff --git a/crates/jmap-proto/src/method/parse.rs b/crates/jmap-proto/src/method/parse.rs index ce7ac697..9af13f02 100644 --- a/crates/jmap-proto/src/method/parse.rs +++ b/crates/jmap-proto/src/method/parse.rs @@ -9,6 +9,7 @@ use crate::{ request::{ MaybeInvalid, deserialize::{DeserializeArguments, deserialize_request}, + reference::MaybeIdReference, }, }; use jmap_tools::Value; @@ -19,7 +20,7 @@ use utils::map::vec_map::VecMap; #[derive(Debug, Clone)] pub struct ParseRequest { pub account_id: Id, - pub blob_ids: Vec>, + pub blob_ids: Vec>, pub properties: Option>>, pub arguments: T::ParseArguments, } diff --git a/crates/jmap-proto/src/object/addressbook.rs b/crates/jmap-proto/src/object/addressbook.rs index 765029d7..ffff813d 100644 --- a/crates/jmap-proto/src/object/addressbook.rs +++ b/crates/jmap-proto/src/object/addressbook.rs @@ -292,21 +292,10 @@ impl JmapObjectId for AddressBookValue { } impl JmapRight for AddressBookRight { - fn from_acl(acl: Acl) -> &'static [Self] { - match acl { - Acl::ReadItems => &[AddressBookRight::MayRead], - Acl::RemoveItems => &[AddressBookRight::MayDelete], - Acl::ModifyItems => &[AddressBookRight::MayWrite], - Acl::Delete => &[AddressBookRight::MayDelete], - Acl::Administer => &[AddressBookRight::MayShare], - _ => &[], - } - } - fn to_acl(&self) -> &'static [Acl] { match self { AddressBookRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], - AddressBookRight::MayShare => &[Acl::Administer], + AddressBookRight::MayShare => &[Acl::Share], AddressBookRight::MayRead => &[Acl::Read, Acl::ReadItems], AddressBookRight::MayWrite => &[Acl::Modify, Acl::AddItems, Acl::ModifyItems], } @@ -357,3 +346,9 @@ impl JmapObjectId for AddressBookProperty { false } } + +impl std::fmt::Display for AddressBookProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/calendar.rs b/crates/jmap-proto/src/object/calendar.rs index 6254fd11..22fb55c8 100644 --- a/crates/jmap-proto/src/object/calendar.rs +++ b/crates/jmap-proto/src/object/calendar.rs @@ -400,22 +400,6 @@ impl JmapObjectId for CalendarValue { } impl JmapRight for CalendarRight { - fn from_acl(acl: Acl) -> &'static [Self] { - match acl { - Acl::ReadItems => &[CalendarRight::MayReadItems], - Acl::RemoveItems => &[CalendarRight::MayWriteAll], - Acl::ModifyItems => &[CalendarRight::MayWriteAll], - Acl::AddItems => &[CalendarRight::MayWriteAll], - Acl::Delete => &[CalendarRight::MayDelete], - Acl::Administer => &[CalendarRight::MayShare], - Acl::SchedulingReadFreeBusy => &[CalendarRight::MayReadFreeBusy], - Acl::ModifyItemsOwn => &[CalendarRight::MayWriteOwn], - Acl::ModifyPrivateProperties => &[CalendarRight::MayUpdatePrivate], - Acl::ModifyRSVP => &[CalendarRight::MayRSVP], - _ => &[], - } - } - fn to_acl(&self) -> &'static [Acl] { match self { CalendarRight::MayReadFreeBusy => &[Acl::SchedulingReadFreeBusy], @@ -429,7 +413,7 @@ impl JmapRight for CalendarRight { CalendarRight::MayWriteOwn => &[Acl::ModifyItemsOwn], CalendarRight::MayUpdatePrivate => &[Acl::ModifyPrivateProperties], CalendarRight::MayRSVP => &[Acl::ModifyRSVP], - CalendarRight::MayShare => &[Acl::Administer], + CalendarRight::MayShare => &[Acl::Share], CalendarRight::MayDelete => &[Acl::Delete], } } diff --git a/crates/jmap-proto/src/object/contact.rs b/crates/jmap-proto/src/object/contact.rs index d215afd6..a399a36a 100644 --- a/crates/jmap-proto/src/object/contact.rs +++ b/crates/jmap-proto/src/object/contact.rs @@ -120,7 +120,7 @@ impl<'de> DeserializeArguments<'de> for ContactCardFilter { A: serde::de::MapAccess<'de>, { hashify::fnc_map!(key.as_bytes(), - b"inContactCard" => { + b"inAddressBook" => { *self = ContactCardFilter::InAddressBook(map.next_value()?); }, b"uid" => { @@ -226,7 +226,7 @@ impl<'de> DeserializeArguments<'de> for ContactCardComparator { impl ContactCardFilter { pub fn into_string(self) -> Cow<'static, str> { match self { - ContactCardFilter::InAddressBook(_) => "inContactCard", + ContactCardFilter::InAddressBook(_) => "inAddressBook", ContactCardFilter::Uid(_) => "uid", ContactCardFilter::HasMember(_) => "hasMember", ContactCardFilter::Kind(_) => "kind", diff --git a/crates/jmap-proto/src/object/file_node.rs b/crates/jmap-proto/src/object/file_node.rs index a62dffd7..5efb4070 100644 --- a/crates/jmap-proto/src/object/file_node.rs +++ b/crates/jmap-proto/src/object/file_node.rs @@ -267,21 +267,10 @@ impl From for FileNodeProperty { } impl JmapRight for FileNodeRight { - fn from_acl(acl: Acl) -> &'static [Self] { - match acl { - Acl::ReadItems => &[FileNodeRight::MayRead], - Acl::RemoveItems => &[FileNodeRight::MayDelete], - Acl::ModifyItems => &[FileNodeRight::MayWrite], - Acl::Delete => &[FileNodeRight::MayDelete], - Acl::Administer => &[FileNodeRight::MayShare], - _ => &[], - } - } - fn to_acl(&self) -> &'static [Acl] { match self { FileNodeRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], - FileNodeRight::MayShare => &[Acl::Administer], + FileNodeRight::MayShare => &[Acl::Share], FileNodeRight::MayRead => &[Acl::Read, Acl::ReadItems], FileNodeRight::MayWrite => &[Acl::Modify, Acl::AddItems, Acl::ModifyItems], } diff --git a/crates/jmap-proto/src/object/mailbox.rs b/crates/jmap-proto/src/object/mailbox.rs index 2b2e1a90..dbdfc964 100644 --- a/crates/jmap-proto/src/object/mailbox.rs +++ b/crates/jmap-proto/src/object/mailbox.rs @@ -447,21 +447,6 @@ impl JmapObjectId for MailboxValue { } impl JmapRight for MailboxRight { - fn from_acl(acl: Acl) -> &'static [Self] { - match acl { - Acl::ReadItems => &[MailboxRight::MayReadItems], - Acl::AddItems => &[MailboxRight::MayAddItems], - Acl::RemoveItems => &[MailboxRight::MayRemoveItems], - Acl::ModifyItems => &[MailboxRight::MaySetSeen, MailboxRight::MaySetKeywords], - Acl::CreateChild => &[MailboxRight::MayCreateChild], - Acl::Modify => &[MailboxRight::MayRename], - Acl::Submit => &[MailboxRight::MaySubmit], - Acl::Delete => &[MailboxRight::MayDelete], - Acl::Administer => &[MailboxRight::MayShare], - _ => &[], - } - } - fn to_acl(&self) -> &'static [Acl] { match self { MailboxRight::MayReadItems => &[Acl::Read, Acl::ReadItems], @@ -473,7 +458,7 @@ impl JmapRight for MailboxRight { MailboxRight::MayRename => &[Acl::Modify], MailboxRight::MaySubmit => &[Acl::Submit], MailboxRight::MayDelete => &[Acl::Delete], - MailboxRight::MayShare => &[Acl::Administer], + MailboxRight::MayShare => &[Acl::Share], } } diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index ebab1663..ba3ab3d2 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -55,7 +55,6 @@ pub trait JmapSharedObject: JmapObject { } pub trait JmapRight: Clone + Copy + Sized + 'static { - fn from_acl(acl: Acl) -> &'static [Self]; fn all_rights() -> &'static [Self]; fn to_acl(&self) -> &'static [Acl]; } @@ -167,10 +166,6 @@ impl JmapObject for NullObject { } impl JmapRight for Null { - fn from_acl(_: Acl) -> &'static [Self] { - unreachable!() - } - fn all_rights() -> &'static [Self] { unreachable!() } diff --git a/crates/jmap-proto/src/object/share_notification.rs b/crates/jmap-proto/src/object/share_notification.rs index f779e40d..afc2944f 100644 --- a/crates/jmap-proto/src/object/share_notification.rs +++ b/crates/jmap-proto/src/object/share_notification.rs @@ -10,7 +10,7 @@ use crate::{ types::date::UTCDate, }; use jmap_tools::{Element, Key, Property}; -use std::{borrow::Cow, str::FromStr}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; use types::{id::Id, type_state::DataType}; #[derive(Debug, Clone, Default)] @@ -300,3 +300,9 @@ impl JmapObjectId for ShareNotificationProperty { false } } + +impl Display for ShareNotificationProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index da7f1eb3..bbc0cb1f 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -15,7 +15,7 @@ use crate::{ }; use compact_str::format_compact; use jmap_tools::{Element, Key, Property, Value}; -use types::id::Id; +use types::{blob::BlobId, id::Id}; impl Response<'_> { pub(crate) fn eval_result_references(&self, rr: &ResultReference) -> trc::Result { @@ -113,6 +113,9 @@ impl Response<'_> { ChangesResponseMethod::FileNode(response) => { response.eval_jptr(path, &mut results) } + ChangesResponseMethod::Calendar(response) => { + response.eval_jptr(path, &mut results) + } ChangesResponseMethod::CalendarEvent(response) => { response.eval_jptr(path, &mut results) } @@ -154,6 +157,16 @@ impl Response<'_> { .details(format_compact!("Id reference {ir:?} not found."))) } } + + pub(crate) fn eval_blob_id_reference(&self, ir: &str) -> trc::Result { + if let Some(AnyId::BlobId(id)) = self.created_ids.get(ir) { + Ok(id.clone()) + } else { + Err(trc::JmapEvent::InvalidResultReference + .into_err() + .details(format_compact!("blobId reference {ir:?} not found."))) + } + } } pub(crate) trait EvalObjectReferences { diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index b7af9f17..130afb58 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -10,6 +10,7 @@ use crate::{ copy::CopyRequest, get::GetRequest, import::ImportEmailRequest, + parse::ParseRequest, search_snippet::GetSearchSnippetRequest, set::{SetRequest, SetResponse}, upload::{BlobUploadRequest, DataSourceObject}, @@ -17,7 +18,8 @@ use crate::{ object::{AnyId, JmapObject, JmapObjectId}, references::{Graph, eval::EvalObjectReferences, topological_sort}, request::{ - CopyRequestMethod, GetRequestMethod, MaybeInvalid, RequestMethod, SetRequestMethod, + CopyRequestMethod, GetRequestMethod, MaybeInvalid, ParseRequestMethod, RequestMethod, + SetRequestMethod, reference::{MaybeIdReference, MaybeResultReference}, }, response::Response, @@ -86,6 +88,11 @@ impl Response<'_> { RequestMethod::ImportEmail(request) => request.resolve_references(self)?, RequestMethod::SearchSnippet(request) => request.resolve_references(self)?, RequestMethod::UploadBlob(request) => request.resolve_references(self)?, + RequestMethod::Parse(request) => match request { + ParseRequestMethod::Email(request) => request.resolve_references(self)?, + ParseRequestMethod::ContactCard(request) => request.resolve_references(self)?, + ParseRequestMethod::CalendarEvent(request) => request.resolve_references(self)?, + }, _ => {} } @@ -241,6 +248,19 @@ impl<'x, T: JmapObject> ResolveReference for CopyRequest<'x, T> { } } +impl ResolveReference for ParseRequest { + fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { + // Resolve blobId references + for id in self.blob_ids.iter_mut() { + if let MaybeIdReference::Reference(ir) = id { + *id = MaybeIdReference::Id(response.eval_blob_id_reference(ir)?); + } + } + + Ok(()) + } +} + impl ResolveReference for ImportEmailRequest { fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> { // Resolve email mailbox references diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 7ab8fb8f..b3643dd1 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -202,6 +202,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Get, MethodObject::ShareNotification) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ShareNotification(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Get, MethodObject::SearchSnippet) => match seq.next_element() { Ok(Some(value)) => RequestMethod::SearchSnippet(value), Err(err) => RequestMethod::invalid(err), @@ -279,6 +286,13 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Set, MethodObject::ShareNotification) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ShareNotification(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::Query, MethodObject::Email) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::Email(value)), Err(err) => RequestMethod::invalid(err), @@ -335,6 +349,15 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::Query, MethodObject::ShareNotification) => match seq.next_element() { + Ok(Some(value)) => { + RequestMethod::Query(QueryRequestMethod::ShareNotification(value)) + } + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, (MethodFunction::QueryChanges, MethodObject::Email) => match seq.next_element() { Ok(Some(value)) => { RequestMethod::QueryChanges(QueryChangesRequestMethod::Email(value)) @@ -409,6 +432,17 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + (MethodFunction::QueryChanges, MethodObject::ShareNotification) => { + match seq.next_element() { + Ok(Some(value)) => RequestMethod::QueryChanges( + QueryChangesRequestMethod::ShareNotification(value), + ), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + } + } (MethodFunction::Changes, _) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Changes(value), Err(err) => RequestMethod::invalid(err), @@ -486,7 +520,12 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, - _ => unreachable!(), + _ => { + return Err(de::Error::custom(format!( + "Invalid method function/object combination: {}", + method_name + ))); + } }; let id = seq diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 928cccbb..06b14ea1 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -130,6 +130,7 @@ pub enum ChangesResponseMethod { AddressBook(ChangesResponse), ContactCard(ChangesResponse), FileNode(ChangesResponse), + Calendar(ChangesResponse), CalendarEvent(ChangesResponse), CalendarEventNotification(ChangesResponse), ShareNotification(ChangesResponse), diff --git a/crates/jmap/src/addressbook/set.rs b/crates/jmap/src/addressbook/set.rs index c4049609..fe63d823 100644 --- a/crates/jmap/src/addressbook/set.rs +++ b/crates/jmap/src/addressbook/set.rs @@ -9,7 +9,7 @@ use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; use groupware::{ DestroyArchive, cache::GroupwareCache, - contact::{AddressBook, AddressBookPreferences}, + contact::{AddressBook, AddressBookPreferences, ContactCard}, }; use http_proto::HttpSessionData; use jmap_proto::{ @@ -21,10 +21,14 @@ use jmap_proto::{ }; use jmap_tools::{JsonPointerItem, Key, Value}; use rand::{Rng, distr::Alphanumeric}; -use store::{SerializeInfallible, write::BatchBuilder}; +use store::{ + SerializeInfallible, ValueKey, + ahash::AHashSet, + write::{BatchBuilder, ValueClass}, +}; use trc::AddContext; use types::{ - acl::{Acl, AclGrant}, + acl::Acl, collection::{Collection, SyncCollection}, field::PrincipalField, }; @@ -155,8 +159,7 @@ impl AddressBookSet for Server { // Validate ACL if is_shared { let acl = address_book.inner.acls.effective_acl(access_token); - if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Administer)) - { + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Share)) { response.not_updated.append( id, SetError::forbidden() @@ -170,17 +173,9 @@ impl AddressBookSet for Server { response.not_updated.append(id, err.into()); continue 'update; } - self.refresh_acls( + self.refresh_archived_acls( &new_address_book.acls, - Some( - address_book - .inner - .acls - .iter() - .map(AclGrant::from) - .collect::>() - .as_slice(), - ), + address_book.inner.acls.as_slice(), ) .await; } @@ -199,71 +194,129 @@ impl AddressBookSet for Server { } // Process deletions - let on_destroy_remove_contents = request - .arguments - .on_destroy_remove_contents - .unwrap_or(false); - for id in will_destroy { - let document_id = id.document_id(); - - if !cache.has_container_id(&document_id) { - response.not_destroyed.append(id, SetError::not_found()); - continue; - }; - - let Some(address_book_) = self - .get_archive(account_id, Collection::AddressBook, document_id) - .await - .caused_by(trc::location!())? - else { - response.not_destroyed.append(id, SetError::not_found()); - continue; - }; - - let address_book = address_book_ - .to_unarchived::() - .caused_by(trc::location!())?; - - // Validate ACLs - if is_shared - && !address_book - .inner - .acls - .effective_acl(access_token) - .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) - { - response.not_destroyed.append( - id, - SetError::forbidden() - .with_description("You are not allowed to delete this address book."), - ); - continue; - } - - // Obtain children ids - let children_ids = cache.children_ids(document_id).collect::>(); - if !children_ids.is_empty() && !on_destroy_remove_contents { - response - .not_destroyed - .append(id, SetError::address_book_has_contents()); - continue; - } - - // Delete record - DestroyArchive(address_book) - .delete_with_cards( - self, - access_token, + let mut reset_default_address_book = false; + if !will_destroy.is_empty() { + let mut destroy_children = AHashSet::new(); + let mut destroy_parents = AHashSet::new(); + let default_address_book_id = self + .store() + .get_value::(ValueKey { account_id, - document_id, - children_ids, - None, - &mut batch, - ) + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultAddressBookId.into()), + }) .await .caused_by(trc::location!())?; - response.destroyed.push(id); + let on_destroy_remove_contents = request + .arguments + .on_destroy_remove_contents + .unwrap_or(false); + + for id in will_destroy { + let document_id = id.document_id(); + + if !cache.has_container_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(address_book_) = self + .get_archive(account_id, Collection::AddressBook, document_id) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let address_book = address_book_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if is_shared + && !address_book + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this address book."), + ); + continue; + } + + // Obtain children ids + let children_ids = cache.children_ids(document_id).collect::>(); + if !children_ids.is_empty() && !on_destroy_remove_contents { + response + .not_destroyed + .append(id, SetError::address_book_has_contents()); + continue; + } + destroy_children.extend(children_ids.iter().copied()); + destroy_parents.insert(document_id); + + // Delete record + DestroyArchive(address_book) + .delete(access_token, account_id, document_id, None, &mut batch) + .caused_by(trc::location!())?; + + if default_address_book_id == Some(document_id) { + reset_default_address_book = true; + } + + response.destroyed.push(id); + } + + // Delete children + if !destroy_children.is_empty() { + for document_id in destroy_children { + if let Some(card_) = self + .get_archive(account_id, Collection::ContactCard, document_id) + .await? + { + let card = card_ + .to_unarchived::() + .caused_by(trc::location!())?; + + if card + .inner + .names + .iter() + .all(|n| destroy_parents.contains(&n.parent_id.to_native())) + { + // Card only belongs to address books being deleted, delete it + DestroyArchive(card).delete_all( + access_token, + account_id, + document_id, + &mut batch, + )?; + } else { + // Unlink addressbook id from card + let mut new_card = card + .deserialize::() + .caused_by(trc::location!())?; + new_card + .names + .retain(|n| !destroy_parents.contains(&n.parent_id)); + new_card.update( + access_token, + card, + account_id, + document_id, + &mut batch, + )?; + } + } + } + } } // Set default address book @@ -279,6 +332,12 @@ impl AddressBookSet for Server { PrincipalField::DefaultAddressBookId, default_address_book_id.serialize(), ); + } else if reset_default_address_book { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .clear(PrincipalField::DefaultAddressBookId); } // Write changes diff --git a/crates/jmap/src/api/acl.rs b/crates/jmap/src/api/acl.rs index 77907a4e..d1466550 100644 --- a/crates/jmap/src/api/acl.rs +++ b/crates/jmap/src/api/acl.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, auth::AccessToken}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; use jmap_proto::{ error::set::SetError, object::{JmapRight, JmapSharedObject}, @@ -183,10 +183,11 @@ impl JmapRights { ) -> Value<'static, T::Property, T::Element> { let mut obj = Map::with_capacity(3); - for acl in acls.into_iter() { - for right in T::Right::from_acl(acl) { - obj.insert_unchecked(Key::Property((*right).into()), Value::Bool(true)); - } + for right in T::Right::all_rights() { + obj.insert_unchecked( + Key::Property((*right).into()), + Value::Bool(right.to_acl().iter().all(|acl| acls.contains(*acl))), + ); } Value::Object(obj) @@ -201,9 +202,7 @@ impl JmapRights { T::Property: From, { if access_token.is_member(account_id) - || grants.iter().any(|item| { - item.grants.contains(Acl::Administer) && access_token.is_member(item.account_id) - }) + || grants.effective_acl(access_token).contains(Acl::Share) { let mut share_with = Map::with_capacity(grants.len()); for grant in grants { diff --git a/crates/jmap/src/calendar/set.rs b/crates/jmap/src/calendar/set.rs index f5903f3e..9858e606 100644 --- a/crates/jmap/src/calendar/set.rs +++ b/crates/jmap/src/calendar/set.rs @@ -13,7 +13,7 @@ use groupware::{ calendar::{ ALERT_EMAIL, ALERT_RELATIVE_TO_END, ALERT_WITH_TIME, CALENDAR_AVAILABILITY_ALL, CALENDAR_AVAILABILITY_ATTENDING, CALENDAR_AVAILABILITY_NONE, CALENDAR_INVISIBLE, - CALENDAR_SUBSCRIBED, Calendar, CalendarPreferences, DefaultAlert, Timezone, + CALENDAR_SUBSCRIBED, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone, }, }; use http_proto::HttpSessionData; @@ -26,10 +26,14 @@ use jmap_proto::{ }; use jmap_tools::{JsonPointerItem, Key, Value}; use rand::{Rng, distr::Alphanumeric}; -use store::{SerializeInfallible, write::BatchBuilder}; +use store::{ + SerializeInfallible, ValueKey, + ahash::AHashSet, + write::{BatchBuilder, ValueClass}, +}; use trc::AddContext; use types::{ - acl::{Acl, AclGrant}, + acl::Acl, collection::{Collection, SyncCollection}, field::PrincipalField, }; @@ -159,8 +163,7 @@ impl CalendarSet for Server { // Validate ACL if is_shared { let acl = calendar.inner.acls.effective_acl(access_token); - if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Administer)) - { + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Share)) { response.not_updated.append( id, SetError::forbidden() @@ -174,19 +177,8 @@ impl CalendarSet for Server { response.not_updated.append(id, err.into()); continue 'update; } - self.refresh_acls( - &new_calendar.acls, - Some( - calendar - .inner - .acls - .iter() - .map(AclGrant::from) - .collect::>() - .as_slice(), - ), - ) - .await; + self.refresh_archived_acls(&new_calendar.acls, calendar.inner.acls.as_slice()) + .await; } // Update record @@ -197,69 +189,126 @@ impl CalendarSet for Server { } // Process deletions - let on_destroy_remove_events = request.arguments.on_destroy_remove_events.unwrap_or(false); - for id in will_destroy { - let document_id = id.document_id(); - - if !cache.has_container_id(&document_id) { - response.not_destroyed.append(id, SetError::not_found()); - continue; - }; - - let Some(calendar_) = self - .get_archive(account_id, Collection::Calendar, document_id) - .await - .caused_by(trc::location!())? - else { - response.not_destroyed.append(id, SetError::not_found()); - continue; - }; - - let calendar = calendar_ - .to_unarchived::() - .caused_by(trc::location!())?; - - // Validate ACLs - if is_shared - && !calendar - .inner - .acls - .effective_acl(access_token) - .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) - { - response.not_destroyed.append( - id, - SetError::forbidden() - .with_description("You are not allowed to delete this calendar."), - ); - continue; - } - - // Obtain children ids - let children_ids = cache.children_ids(document_id).collect::>(); - if !children_ids.is_empty() && !on_destroy_remove_events { - response - .not_destroyed - .append(id, SetError::calendar_has_event()); - continue; - } - - // Delete record - DestroyArchive(calendar) - .delete_with_events( - self, - access_token, + let mut reset_default_calendar = false; + if !will_destroy.is_empty() { + let mut destroy_children = AHashSet::new(); + let mut destroy_parents = AHashSet::new(); + let default_calendar_id = self + .store() + .get_value::(ValueKey { account_id, - document_id, - children_ids, - None, - false, - &mut batch, - ) + collection: Collection::Principal.into(), + document_id: 0, + class: ValueClass::Property(PrincipalField::DefaultCalendarId.into()), + }) .await .caused_by(trc::location!())?; + let on_destroy_remove_events = + request.arguments.on_destroy_remove_events.unwrap_or(false); + for id in will_destroy { + let document_id = id.document_id(); - response.destroyed.push(id); + if !cache.has_container_id(&document_id) { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let Some(calendar_) = self + .get_archive(account_id, Collection::Calendar, document_id) + .await + .caused_by(trc::location!())? + else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + + let calendar = calendar_ + .to_unarchived::() + .caused_by(trc::location!())?; + + // Validate ACLs + if is_shared + && !calendar + .inner + .acls + .effective_acl(access_token) + .contains_all([Acl::Delete, Acl::RemoveItems].into_iter()) + { + response.not_destroyed.append( + id, + SetError::forbidden() + .with_description("You are not allowed to delete this calendar."), + ); + continue; + } + + // Obtain children ids + let children_ids = cache.children_ids(document_id).collect::>(); + if !children_ids.is_empty() && !on_destroy_remove_events { + response + .not_destroyed + .append(id, SetError::calendar_has_event()); + continue; + } + destroy_children.extend(children_ids.iter().copied()); + destroy_parents.insert(document_id); + + // Delete record + DestroyArchive(calendar) + .delete(access_token, account_id, document_id, None, &mut batch) + .caused_by(trc::location!())?; + + if default_calendar_id == Some(document_id) { + reset_default_calendar = true; + } + + response.destroyed.push(id); + } + + // Delete children + if !destroy_children.is_empty() { + for document_id in destroy_children { + if let Some(event_) = self + .get_archive(account_id, Collection::CalendarEvent, document_id) + .await? + { + let event = event_ + .to_unarchived::() + .caused_by(trc::location!())?; + + if event + .inner + .names + .iter() + .all(|n| destroy_parents.contains(&n.parent_id.to_native())) + { + // Event only belongs to calendars being deleted, delete it + DestroyArchive(event).delete_all( + access_token, + account_id, + document_id, + false, + &mut batch, + )?; + } else { + // Unlink calendar id from event + let mut new_event = event + .deserialize::() + .caused_by(trc::location!())?; + new_event + .names + .retain(|n| !destroy_parents.contains(&n.parent_id)); + new_event.update( + access_token, + event, + account_id, + document_id, + &mut batch, + )?; + } + } + } + } } // Set default calendar @@ -275,6 +324,12 @@ impl CalendarSet for Server { PrincipalField::DefaultCalendarId, default_calendar_id.serialize(), ); + } else if reset_default_calendar { + batch + .with_account_id(account_id) + .with_collection(Collection::Principal) + .update_document(0) + .clear(PrincipalField::DefaultCalendarId); } // Write changes diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index 36ade53f..3381732f 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -104,7 +104,7 @@ impl JmapCalendarEventCopy for Server { response.not_created.append( id, SetError::not_found().with_description(format!( - "Item {} not found not found in account {}.", + "Item {} not found in account {}.", id, response.from_account_id )), ); @@ -125,7 +125,7 @@ impl JmapCalendarEventCopy for Server { let Some(_calendar_event) = self .get_archive( - account_id, + from_account_id, Collection::CalendarEvent, from_calendar_event_id, ) @@ -134,7 +134,7 @@ impl JmapCalendarEventCopy for Server { response.not_created.append( id, SetError::not_found().with_description(format!( - "Item {} not found not found in account {}.", + "Item {} not found in account {}.", id, response.from_account_id )), ); diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index 5de975e0..48a665a4 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -72,7 +72,7 @@ impl CalendarEventGet for Server { let calendar_event_ids = if access_token.is_member(account_id) { cache.document_ids(false).collect::() } else { - cache.shared_containers(access_token, [Acl::ReadItems], true) + cache.shared_items(access_token, [Acl::ReadItems], true) }; let mut ids = if let Some(ids) = ids { ids diff --git a/crates/jmap/src/calendar_event/query.rs b/crates/jmap/src/calendar_event/query.rs index 4e983a5e..3b464e8c 100644 --- a/crates/jmap/src/calendar_event/query.rs +++ b/crates/jmap/src/calendar_event/query.rs @@ -14,8 +14,9 @@ use jmap_proto::{ object::calendar_event::{self, CalendarEventComparator, CalendarEventFilter}, request::MaybeInvalid, }; +use nlp::tokenizers::word::WordTokenizer; use std::{cmp::Ordering, sync::Arc}; -use store::{query, roaring::RoaringBitmap}; +use store::{backend::MAX_TOKEN_LENGTH, query, roaring::RoaringBitmap}; use trc::AddContext; use types::{ TimeRange, @@ -75,7 +76,12 @@ impl CalendarEventQuery for Server { filters.push(query::Filter::eq(CalendarField::Uid, uid.into_bytes())) } CalendarEventFilter::Text(value) => { - filters.push(query::Filter::has_text(CalendarField::Text, value)) + for token in WordTokenizer::new(&value, MAX_TOKEN_LENGTH) { + filters.push(query::Filter::eq( + CalendarField::Text, + token.word.into_owned().into_bytes(), + )); + } } CalendarEventFilter::After(_) => { /* diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index d2e3efb4..695c149e 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{borrow::Cow, str::FromStr}; - use crate::calendar_event::{CalendarSyntheticId, assert_is_unique_uid}; use calcard::{ common::timezone::Tz, @@ -38,6 +36,7 @@ use jmap_proto::{ types::state::State, }; use jmap_tools::{JsonPointerHandler, JsonPointerItem, Key, Map, Value}; +use std::{borrow::Cow, str::FromStr}; use store::{ ahash::AHashSet, roaring::RoaringBitmap, @@ -428,7 +427,7 @@ impl CalendarEventSet for Server { } // Process deletions - for id in will_destroy { + 'destroy: for id in will_destroy { let document_id = id.document_id(); if !cache.has_container_id(&document_id) { @@ -469,7 +468,7 @@ impl CalendarEventSet for Server { Id::from(parent_id) )), ); - continue; + continue 'destroy; } } } @@ -912,7 +911,7 @@ fn patch_parent_ids( }) .collect::>(); - current.retain(|name| !new_ids.remove(&name.parent_id)); + current.retain(|name| new_ids.remove(&name.parent_id)); for id in new_ids { current.push(DavName::new_with_rand_name(id)); diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 8a54249e..82052c29 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -65,9 +65,14 @@ impl ChangesLookup for Server { (SyncCollection::EmailSubmission, false) } - MethodObject::ContactCard => { + MethodObject::AddressBook => { access_token.assert_has_access(request.account_id, Collection::AddressBook)?; + (SyncCollection::AddressBook, true) + } + MethodObject::ContactCard => { + access_token.assert_has_access(request.account_id, Collection::ContactCard)?; + (SyncCollection::AddressBook, false) } MethodObject::FileNode => { @@ -75,9 +80,14 @@ impl ChangesLookup for Server { (SyncCollection::FileNode, true) } - MethodObject::CalendarEvent => { + MethodObject::Calendar => { access_token.assert_has_access(request.account_id, Collection::Calendar)?; + (SyncCollection::Calendar, true) + } + MethodObject::CalendarEvent => { + access_token.assert_has_access(request.account_id, Collection::CalendarEvent)?; + (SyncCollection::Calendar, false) } MethodObject::CalendarEventNotification => { @@ -252,12 +262,18 @@ impl IntermediateChangesResponse { MethodObject::EmailSubmission => { ChangesResponseMethod::EmailSubmission(transmute_response(self.response)) } + MethodObject::AddressBook => { + ChangesResponseMethod::AddressBook(transmute_response(self.response)) + } MethodObject::ContactCard => { ChangesResponseMethod::ContactCard(transmute_response(self.response)) } MethodObject::FileNode => { ChangesResponseMethod::FileNode(transmute_response(self.response)) } + MethodObject::Calendar => { + ChangesResponseMethod::Calendar(transmute_response(self.response)) + } MethodObject::CalendarEvent => { ChangesResponseMethod::CalendarEvent(transmute_response(self.response)) } @@ -268,7 +284,6 @@ impl IntermediateChangesResponse { ChangesResponseMethod::ShareNotification(transmute_response(self.response)) } MethodObject::ParticipantIdentity - | MethodObject::Calendar | MethodObject::Core | MethodObject::Blob | MethodObject::PushSubscription @@ -276,8 +291,7 @@ impl IntermediateChangesResponse { | MethodObject::VacationResponse | MethodObject::SieveScript | MethodObject::Principal - | MethodObject::Quota - | MethodObject::AddressBook => unreachable!(), + | MethodObject::Quota => unreachable!(), }) } } diff --git a/crates/jmap/src/contact/copy.rs b/crates/jmap/src/contact/copy.rs index f9298a7f..30a682ff 100644 --- a/crates/jmap/src/contact/copy.rs +++ b/crates/jmap/src/contact/copy.rs @@ -99,7 +99,7 @@ impl JmapContactCardCopy for Server { response.not_created.append( id, SetError::not_found().with_description(format!( - "Item {} not found not found in account {}.", + "Item {} not found in account {}.", id, response.from_account_id )), ); @@ -107,13 +107,13 @@ impl JmapContactCardCopy for Server { } let Some(_contact) = self - .get_archive(account_id, Collection::ContactCard, from_contact_id) + .get_archive(from_account_id, Collection::ContactCard, from_contact_id) .await? else { response.not_created.append( id, SetError::not_found().with_description(format!( - "Item {} not found not found in account {}.", + "Item {} not found in account {}.", id, response.from_account_id )), ); diff --git a/crates/jmap/src/contact/get.rs b/crates/jmap/src/contact/get.rs index 7e05dccc..19fac762 100644 --- a/crates/jmap/src/contact/get.rs +++ b/crates/jmap/src/contact/get.rs @@ -51,7 +51,7 @@ impl ContactCardGet for Server { let contact_ids = if access_token.is_member(account_id) { cache.document_ids(false).collect::() } else { - cache.shared_containers(access_token, [Acl::ReadItems], true) + cache.shared_items(access_token, [Acl::ReadItems], true) }; let ids = if let Some(ids) = ids { ids diff --git a/crates/jmap/src/contact/query.rs b/crates/jmap/src/contact/query.rs index 0bcdb338..5f5504e0 100644 --- a/crates/jmap/src/contact/query.rs +++ b/crates/jmap/src/contact/query.rs @@ -11,7 +11,8 @@ use jmap_proto::{ object::contact::{ContactCard, ContactCardComparator, ContactCardFilter}, request::MaybeInvalid, }; -use store::{SerializeInfallible, query, roaring::RoaringBitmap}; +use nlp::tokenizers::word::WordTokenizer; +use store::{SerializeInfallible, backend::MAX_TOKEN_LENGTH, query, roaring::RoaringBitmap}; use types::{ acl::Acl, collection::{Collection, SyncCollection}, @@ -58,10 +59,14 @@ impl ContactCardQuery for Server { ContactField::Email, sanitize_email(&email).unwrap_or(email).into_bytes(), )), - ContactCardFilter::Text(value) => filters.push(query::Filter::has_text( - ContactField::Text, - value.to_lowercase(), - )), + ContactCardFilter::Text(value) => { + for token in WordTokenizer::new(&value, MAX_TOKEN_LENGTH) { + filters.push(query::Filter::eq( + ContactField::Text, + token.word.into_owned().into_bytes(), + )); + } + } ContactCardFilter::CreatedBefore(before) => filters.push(query::Filter::lt( ContactField::Created, (before.timestamp() as u64).serialize(), diff --git a/crates/jmap/src/contact/set.rs b/crates/jmap/src/contact/set.rs index e52d7113..34d8597b 100644 --- a/crates/jmap/src/contact/set.rs +++ b/crates/jmap/src/contact/set.rs @@ -272,7 +272,7 @@ impl ContactCardSet for Server { } // Process deletions - for id in will_destroy { + 'destroy: for id in will_destroy { let document_id = id.document_id(); if !cache.has_container_id(&document_id) { @@ -305,7 +305,7 @@ impl ContactCardSet for Server { Id::from(parent_id) )), ); - continue; + continue 'destroy; } } } @@ -508,7 +508,7 @@ fn patch_parent_ids( }) .collect::>(); - current.retain(|name| !new_ids.remove(&name.parent_id)); + current.retain(|name| new_ids.remove(&name.parent_id)); for id in new_ids { current.push(DavName::new_with_rand_name(id)); diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index cade65ab..502f2765 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -97,7 +97,7 @@ impl JmapEmailCopy for Server { response.not_created.append( id, SetError::not_found().with_description(format!( - "Item {} not found not found in account {}.", + "Item {} not found in account {}.", id, response.from_account_id )), ); @@ -221,7 +221,7 @@ impl JmapEmailCopy for Server { id, match err { CopyMessageError::NotFound => SetError::not_found() - .with_description("Message not found not found in account."), + .with_description("Message not found in account."), CopyMessageError::OverQuota => SetError::over_quota(), }, ); diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index a647ffa8..f24a70eb 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -204,8 +204,7 @@ impl FileNodeSet for Server { // Validate ACL if is_shared { let acl = file_node.inner.acls.effective_acl(access_token); - if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Administer)) - { + if !acl.contains(Acl::Modify) || (has_acl_changes && !acl.contains(Acl::Share)) { response.not_updated.append( id, SetError::forbidden() diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index d1bc8e1a..78f2d3b4 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -177,7 +177,7 @@ impl MailboxSet for Server { ); continue 'update; } else if object.contains_key(&Key::Property(MailboxProperty::ShareWith)) - && !acl.contains(Acl::Administer) + && !acl.contains(Acl::Share) { ctx.response.not_updated.append( id, @@ -459,7 +459,7 @@ impl MailboxSet for Server { && !mailbox .acls .effective_acl(ctx.access_token) - .contains_any([Acl::CreateChild, Acl::Administer].into_iter()) + .contains(Acl::CreateChild) { return Ok(Err(SetError::forbidden().with_description( "You are not allowed to create sub mailboxes under this mailbox.", diff --git a/crates/jmap/src/share_notification/get.rs b/crates/jmap/src/share_notification/get.rs index 93c16bee..a8e10f98 100644 --- a/crates/jmap/src/share_notification/get.rs +++ b/crates/jmap/src/share_notification/get.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{Server, sharing::notification::ShareNotification}; +use common::{Server, auth::AccessToken, sharing::notification::ShareNotification}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::{ @@ -19,9 +19,11 @@ use jmap_proto::{ types::{date::UTCDate, state::State}, }; use jmap_tools::{Key, Map, Value}; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use store::{ - Deserialize, IterateParams, LogKey, U64_LEN, ahash::AHashSet, write::key::DeserializeBigEndian, + Deserialize, IterateParams, LogKey, U64_LEN, + ahash::{AHashMap, AHashSet}, + write::key::DeserializeBigEndian, }; use trc::AddContext; use types::{ @@ -61,6 +63,8 @@ impl ShareNotificationGet for Server { let mut min_id = u64::MAX; let mut max_id = 0u64; + let mut token_cache: AHashMap> = AHashMap::new(); + let mut ids = if let Some(ids) = request.ids.take() { let ids = ids.unwrap(); if ids.len() <= self.core.jmap.get_max_objects { @@ -104,6 +108,7 @@ impl ShareNotificationGet for Server { list: Vec::with_capacity(ids.len()), not_found: vec![], }; + let mut notifications = Vec::new(); self.store() .iterate( @@ -116,7 +121,7 @@ impl ShareNotificationGet for Server { LogKey { account_id, collection: SyncCollection::ShareNotification.into(), - change_id: max_id + 1, + change_id: max_id.saturating_add(1), }, ) .descending(), @@ -127,22 +132,46 @@ impl ShareNotificationGet for Server { } if !has_ids || ids.remove(&change_id) { - let notification = - ShareNotification::deserialize(value).caused_by(trc::location!())?; - response.list.push(build_share_notification( + notifications.push(( change_id, - notification, - &properties, + ShareNotification::deserialize(value).caused_by(trc::location!())?, )); } Ok((!has_ids || !ids.is_empty()) - && response.list.len() < self.core.jmap.get_max_objects) + && notifications.len() < self.core.jmap.get_max_objects) }, ) .await .caused_by(trc::location!())?; + for (change_id, notification) in notifications { + let changed_by_token = if let Some(token) = token_cache.get(¬ification.changed_by) { + token.clone() + } else { + let token = if let Ok(token) = self.get_access_token(notification.changed_by).await + { + token + } else { + Arc::new(AccessToken::from_id(notification.changed_by)) + }; + + token_cache.insert(notification.changed_by, token.clone()); + token + }; + + response.list.push(build_share_notification( + change_id, + notification, + &changed_by_token, + &properties, + )); + } + + if response.state.is_none() { + response.state = Some(State::Initial); + } + response .not_found .extend(ids.into_iter().map(Id::from).collect::>()); @@ -154,6 +183,7 @@ impl ShareNotificationGet for Server { fn build_share_notification( id: u64, mut notification: ShareNotification, + changed_by: &AccessToken, properties: &[ShareNotificationProperty], ) -> Value<'static, ShareNotificationProperty, ShareNotificationValue> { let mut result = Map::with_capacity(properties.len()); @@ -170,7 +200,21 @@ fn build_share_notification( ), ( Key::Property(ShareNotificationProperty::ChangedByName), - Value::Str("".into()), + Value::Str( + changed_by + .description + .as_deref() + .unwrap_or(changed_by.name.as_str()) + .to_string() + .into(), + ), + ), + ( + Key::Property(ShareNotificationProperty::ChangedByEmail), + changed_by + .emails + .first() + .map_or(Value::Null, |email| Value::Str(email.to_string().into())), ), ])), ShareNotificationProperty::ObjectType => DataType::try_from(notification.object_type) @@ -209,31 +253,35 @@ fn map_rights( match object_type { Collection::Calendar | Collection::CalendarEvent => { - for acl in rights.into_iter() { - for right in CalendarRight::from_acl(acl) { - obj.insert_unchecked(Key::Borrowed(right.as_str()), Value::Bool(true)); - } + for right in CalendarRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); } } Collection::AddressBook | Collection::ContactCard => { - for acl in rights.into_iter() { - for right in AddressBookRight::from_acl(acl) { - obj.insert_unchecked(Key::Borrowed(right.as_str()), Value::Bool(true)); - } + for right in AddressBookRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); } } Collection::FileNode => { - for acl in rights.into_iter() { - for right in FileNodeRight::from_acl(acl) { - obj.insert_unchecked(Key::Borrowed(right.as_str()), Value::Bool(true)); - } + for right in FileNodeRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); } } Collection::Mailbox | Collection::Email => { - for acl in rights.into_iter() { - for right in MailboxRight::from_acl(acl) { - obj.insert_unchecked(Key::Borrowed(right.as_str()), Value::Bool(true)); - } + for right in MailboxRight::all_rights() { + obj.insert_unchecked( + Key::Borrowed(right.as_str()), + Value::Bool(right.to_acl().iter().all(|acl| rights.contains(*acl))), + ); } } _ => {} diff --git a/crates/types/src/acl.rs b/crates/types/src/acl.rs index 9f9c568d..a73bbdec 100644 --- a/crates/types/src/acl.rs +++ b/crates/types/src/acl.rs @@ -31,7 +31,7 @@ pub enum Acl { ModifyItems = 5, RemoveItems = 6, CreateChild = 7, - Administer = 8, + Share = 8, Submit = 9, SchedulingReadFreeBusy = 10, SchedulingInvite = 11, @@ -70,7 +70,7 @@ impl Acl { Acl::ModifyItems => "modifyItems", Acl::RemoveItems => "removeItems", Acl::CreateChild => "createChild", - Acl::Administer => "administer", + Acl::Share => "share", Acl::Submit => "submit", Acl::ModifyItemsOwn => "modifyItemsOwn", Acl::ModifyPrivateProperties => "modifyPrivateProperties", @@ -125,7 +125,7 @@ impl From for Acl { 5 => Acl::ModifyItems, 6 => Acl::RemoveItems, 7 => Acl::CreateChild, - 8 => Acl::Administer, + 8 => Acl::Share, 9 => Acl::Submit, 10 => Acl::SchedulingReadFreeBusy, 11 => Acl::SchedulingInvite, diff --git a/crates/utils/src/snowflake.rs b/crates/utils/src/snowflake.rs index 706c7d46..a96f3cec 100644 --- a/crates/utils/src/snowflake.rs +++ b/crates/utils/src/snowflake.rs @@ -71,7 +71,7 @@ impl SnowflakeIdGenerator { } pub fn to_timestamp(id: u64) -> u64 { - (id >> (SEQUENCE_LEN + NODE_ID_LEN)) + DEFAULT_EPOCH + (id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH } pub fn with_node_id(node_id: u64) -> Self { diff --git a/tests/src/cluster/stress.rs b/tests/src/cluster/stress.rs index 1647f153..2272a74a 100644 --- a/tests/src/cluster/stress.rs +++ b/tests/src/cluster/stress.rs @@ -263,7 +263,7 @@ async fn email_tests(server: Server, client: Arc) { wait_for_index(&server).await; destroy_all_mailboxes_no_wait(&client).await; - assert_is_empty(server.clone()).await; + assert_is_empty(&server).await; } } @@ -358,7 +358,7 @@ async fn mailbox_tests(server: Server, client: Arc) { { let _ = client.mailbox_destroy(&mailbox_id, true).await; } - assert_is_empty(server).await; + assert_is_empty(&server).await; } async fn create_mailbox(client: &Client, mailbox: &str) -> Vec { diff --git a/tests/src/jmap/auth/limits.rs b/tests/src/jmap/auth/limits.rs index 1af5e81c..9914f1a9 100644 --- a/tests/src/jmap/auth/limits.rs +++ b/tests/src/jmap/auth/limits.rs @@ -7,7 +7,7 @@ use crate::{ directory::internal::TestInternalDirectory, imap::{ImapConnection, Type}, - jmap::{JMAPTest, assert_is_empty}, + jmap::{JMAPTest}, }; use common::listener::blocked::BLOCKED_IP_KEY; use directory::Permission; @@ -256,7 +256,7 @@ pub async fn test(params: &mut JMAPTest) { // Destroy test accounts params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; // Check webhook events params diff --git a/tests/src/jmap/auth/oauth.rs b/tests/src/jmap/auth/oauth.rs index 14546cbf..05f36488 100644 --- a/tests/src/jmap/auth/oauth.rs +++ b/tests/src/jmap/auth/oauth.rs @@ -9,7 +9,7 @@ use crate::{ ImapConnection, Type, pop::{self, Pop3Connection}, }, - jmap::{JMAPTest, ManagementApi, assert_is_empty, mail::delivery::SmtpConnection}, + jmap::{JMAPTest, ManagementApi, mail::delivery::SmtpConnection}, }; use base64::{Engine, engine::general_purpose}; use biscuit::{JWT, SingleOrMultiple, jwk::JWKSet}; @@ -387,7 +387,7 @@ pub async fn test(params: &mut JMAPTest) { .await .unwrap(); params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } async fn post_bytes( diff --git a/tests/src/jmap/auth/permissions.rs b/tests/src/jmap/auth/permissions.rs index cf8aefef..6450a305 100644 --- a/tests/src/jmap/auth/permissions.rs +++ b/tests/src/jmap/auth/permissions.rs @@ -6,7 +6,7 @@ use crate::{ directory::internal::TestInternalDirectory, - jmap::{JMAPTest, ManagementApi, assert_is_empty, server::List}, + jmap::{JMAPTest, ManagementApi, server::List}, }; use ahash::AHashSet; use common::auth::{AccessToken, TenantInfo}; @@ -762,7 +762,7 @@ pub async fn test(params: &JMAPTest) { .await .unwrap(); - assert_is_empty(server).await; + params.assert_is_empty().await; } const TENANT_QUOTA: u64 = TEST_MESSAGE.len() as u64; diff --git a/tests/src/jmap/auth/quota.rs b/tests/src/jmap/auth/quota.rs index b173c3be..875ff399 100644 --- a/tests/src/jmap/auth/quota.rs +++ b/tests/src/jmap/auth/quota.rs @@ -6,7 +6,7 @@ use crate::{ directory::internal::TestInternalDirectory, - jmap::{JMAPTest, assert_is_empty, emails_purge_tombstoned, mail::delivery::SmtpConnection}, + jmap::{JMAPTest, emails_purge_tombstoned, mail::delivery::SmtpConnection}, smtp::queue::QueuedEvents, }; use common::config::smtp::queue::QueueName; @@ -340,7 +340,7 @@ pub async fn test(params: &mut JMAPTest) { .remove(&server, event.due.into()) .await; } - assert_is_empty(server).await; + params.assert_is_empty().await; } fn assert_over_quota(result: Result) { diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs new file mode 100644 index 00000000..f6336379 --- /dev/null +++ b/tests/src/jmap/calendar/acl.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); +} diff --git a/tests/src/jmap/calendar/calendars.rs b/tests/src/jmap/calendar/calendars.rs new file mode 100644 index 00000000..f6336379 --- /dev/null +++ b/tests/src/jmap/calendar/calendars.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); +} diff --git a/tests/src/jmap/calendar/event.rs b/tests/src/jmap/calendar/event.rs new file mode 100644 index 00000000..f63876cd --- /dev/null +++ b/tests/src/jmap/calendar/event.rs @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + + use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); + + +} diff --git a/tests/src/jmap/calendar/identity.rs b/tests/src/jmap/calendar/identity.rs new file mode 100644 index 00000000..f63876cd --- /dev/null +++ b/tests/src/jmap/calendar/identity.rs @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + + use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); + + +} diff --git a/tests/src/jmap/calendar/mod.rs b/tests/src/jmap/calendar/mod.rs index e63be942..84c054ed 100644 --- a/tests/src/jmap/calendar/mod.rs +++ b/tests/src/jmap/calendar/mod.rs @@ -4,4 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ - \ No newline at end of file +pub mod acl; +pub mod calendars; +pub mod event; +pub mod identity; +pub mod notification; +pub mod principal; diff --git a/tests/src/jmap/calendar/notification.rs b/tests/src/jmap/calendar/notification.rs new file mode 100644 index 00000000..f6336379 --- /dev/null +++ b/tests/src/jmap/calendar/notification.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); +} diff --git a/tests/src/jmap/calendar/principal.rs b/tests/src/jmap/calendar/principal.rs new file mode 100644 index 00000000..f6336379 --- /dev/null +++ b/tests/src/jmap/calendar/principal.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); +} diff --git a/tests/src/jmap/contacts/acl.rs b/tests/src/jmap/contacts/acl.rs new file mode 100644 index 00000000..d1225eff --- /dev/null +++ b/tests/src/jmap/contacts/acl.rs @@ -0,0 +1,603 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use calcard::jscontact::JSContactProperty; +use jmap_proto::{ + object::{addressbook::AddressBookProperty, share_notification::ShareNotificationProperty}, + request::method::MethodObject, +}; +use serde_json::json; +use types::id::Id; + +pub async fn test(params: &mut JMAPTest) { + println!("Running contacts ACL tests..."); + let john = params.account("jdoe@example.com"); + let jane = params.account("jane.smith@example.com"); + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + + // Create test address books + let response = john + .jmap_create( + MethodObject::AddressBook, + [json!({ + "name": "Test #1", + })], + ) + .await; + let john_book_id = response.created(0).id().to_string(); + let john_contact_id = john + .jmap_create( + MethodObject::ContactCard, + [json!({ + "uid": "abc123", + "name": { + "full": "John's Simple Contact", + }, + "addressBookIds": { + &john_book_id: true + }, + })], + ) + .await + .created(0) + .id() + .to_string(); + let response = jane + .jmap_create( + MethodObject::AddressBook, + [json!({ + "name": "Test #1", + })], + ) + .await; + let jane_book_id = response.created(0).id().to_string(); + let jane_contact_id = jane + .jmap_create( + MethodObject::ContactCard, + [json!({ + "uid": "abc456", + "name": { + "full": "Jane's Simple Contact", + }, + "addressBookIds": { + &jane_book_id: true + }, + })], + ) + .await + .created(0) + .id() + .to_string(); + + // Verify myRights + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::MyRights, + AddressBookProperty::ShareWith, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": true + }, + "shareWith": {} + })); + + // Obtain share notifications + let mut jane_share_change_id = jane + .jmap_get( + MethodObject::ShareNotification, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Make sure Jane has no access + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::AddressBook, + Vec::<&str>::new(), + [john_book_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Share address book with Jane + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + "shareWith": { + &jane_id : { + "mayRead": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::ShareWith, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "shareWith": { + &jane_id : { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + } + } + })); + + // Verify Jane can access the contact + jane.jmap_get_account( + john, + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::MyRights, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + } + })); + jane.jmap_get_account( + john, + MethodObject::ContactCard, + [AddressBookProperty::Id, AddressBookProperty::Name], + [john_contact_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_contact_id, + "name": { + "full": "John's Simple Contact" + }, + })); + + // Verify Jane received a share notification + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "AddressBook", + "objectAccountId": &john_id, + "objectId": &john_book_id, + "oldRights": { + "mayRead": false, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "newRights": { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "name": null + })); + + // Updating and deleting should fail + assert_eq!( + jane.jmap_update_account( + john, + MethodObject::AddressBook, + [(&john_book_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_book_id) + .description(), + "You are not allowed to modify this address book." + ); + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::AddressBook, + [&john_book_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_book_id) + .description(), + "You are not allowed to delete this address book." + ); + assert!( + jane.jmap_update_account( + john, + MethodObject::ContactCard, + [(&john_contact_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_contact_id) + .description() + .contains("You are not allowed to modify address book"), + ); + assert!( + jane.jmap_destroy_account( + john, + MethodObject::ContactCard, + [&john_contact_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_contact_id) + .description() + .contains("You are not allowed to remove contacts from address book"), + ); + + // Grant Jane write access + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + format!("shareWith/{jane_id}/mayWrite"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + jane.jmap_get_account( + john, + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::MyRights, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "myRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": false + } + })); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + jane_share_change_id = response.new_state().to_string(); + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "AddressBook", + "objectAccountId": &john_id, + "objectId": &john_book_id, + "oldRights": { + "mayRead": true, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "newRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": false + }, + "name": null + })); + + // Copy Jane's contact into John's address book + let john_copied_contact_id = jane + .jmap_copy( + jane, + john, + MethodObject::ContactCard, + [( + &jane_contact_id, + json!({ + "addressBookIds": { + &john_book_id: true + } + }), + )], + false, + ) + .await + .copied(&jane_contact_id) + .id() + .to_string(); + jane.jmap_get_account( + john, + MethodObject::ContactCard, + [ + JSContactProperty::::Id, + JSContactProperty::AddressBookIds, + JSContactProperty::Name, + ], + [john_copied_contact_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_copied_contact_id, + "name": { + "full": "Jane's Simple Contact" + }, + "addressBookIds": { + &john_book_id: true + } + })); + + // Destroy the copied contact + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::ContactCard, + [john_copied_contact_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [&john_copied_contact_id] + ); + + // Update John's contact + jane.jmap_update_account( + john, + MethodObject::ContactCard, + [( + &john_contact_id, + json!({ + "name": { + "full": "John's Updated Contact", + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_contact_id); + jane.jmap_get_account( + john, + MethodObject::ContactCard, + [JSContactProperty::::Id, JSContactProperty::Name], + [john_contact_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_contact_id, + "name": { + "full": "John's Updated Contact" + }, + })); + + // Revoke Jane's access + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + format!("shareWith/{jane_id}"): () + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + john.jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::ShareWith, + ], + [john_book_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_book_id, + "name": "Test #1", + "shareWith": {} + })); + + // Verify Jane can no longer access the address book or its contacts + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::AddressBook, + Vec::<&str>::new(), + [john_book_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Verify Jane received a share notification with the updated rights + let response = jane + .jmap_changes(MethodObject::ShareNotification, &jane_share_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let share_id = changes[0].as_created(); + jane.jmap_get( + MethodObject::ShareNotification, + [ + ShareNotificationProperty::Id, + ShareNotificationProperty::ChangedBy, + ShareNotificationProperty::ObjectType, + ShareNotificationProperty::ObjectAccountId, + ShareNotificationProperty::ObjectId, + ShareNotificationProperty::OldRights, + ShareNotificationProperty::NewRights, + ShareNotificationProperty::Name, + ], + [share_id], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": &share_id, + "changedBy": { + "principalId": &john_id, + "name": "John Doe", + "email": "jdoe@example.com" + }, + "objectType": "AddressBook", + "objectAccountId": &john_id, + "objectId": &john_book_id, + "oldRights": { + "mayRead": true, + "mayWrite": true, + "mayDelete": true, + "mayShare": false + }, + "newRights": { + "mayRead": false, + "mayWrite": false, + "mayDelete": false, + "mayShare": false + }, + "name": null + })); + + // Grant Jane delete access once again + john.jmap_update( + MethodObject::AddressBook, + [( + &john_book_id, + json!({ + format!("shareWith/{jane_id}/mayRead"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_book_id); + + // Verify Jane can delete the address book + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::AddressBook, + [john_book_id.as_str()], + [("onDestroyRemoveContents", true)], + ) + .await + .destroyed() + .collect::>(), + [john_book_id.as_str()] + ); + + // Destroy all mailboxes + john.destroy_all_addressbooks().await; + jane.destroy_all_addressbooks().await; + params.assert_is_empty().await; +} diff --git a/tests/src/jmap/contacts/addressbook.rs b/tests/src/jmap/contacts/addressbook.rs new file mode 100644 index 00000000..3de38a1d --- /dev/null +++ b/tests/src/jmap/contacts/addressbook.rs @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use jmap_proto::{object::addressbook::AddressBookProperty, request::method::MethodObject}; +use serde_json::json; + +use crate::jmap::{ChangeType, JMAPTest, JmapUtils}; + +pub async fn test(params: &mut JMAPTest) { + println!("Running Address book tests..."); + let account = params.account("jdoe@example.com"); + + // Make sure the default address book exists + let response = account + .jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsSubscribed, + AddressBookProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 1); + let default_addressbook_id = list[0].id().to_string(); + assert_eq!( + list[0], + json!({ + "name": "Stalwart Address Book (jdoe@example.com)", + "description": (), + "sortOrder": 0, + "isSubscribed": false, + "isDefault": true, + "id": default_addressbook_id, + }) + ); + let change_id = response.state(); + + // Create Address Book + let addressbook_id = account + .jmap_create( + MethodObject::AddressBook, + [json!({ + "name": "Test address book", + "description": "My personal address book", + "sortOrder": 1, + "isSubscribed": true + + })], + ) + .await + .created(0) + .id() + .to_string(); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::AddressBook, change_id) + .await + .changes() + .collect::>(), + [ChangeType::Created(&addressbook_id)] + ); + + // Get Address Book + let response = account + .jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsSubscribed, + AddressBookProperty::IsDefault, + ], + [&addressbook_id], + ) + .await; + assert_eq!( + response.list()[0], + json!({ + "name": "Test address book", + "description": "My personal address book", + "sortOrder": 1, + "isSubscribed": true, + "isDefault": false, + "id": addressbook_id, + }) + ); + + // Update Address Book and set it as default + account + .jmap_update( + MethodObject::AddressBook, + [( + addressbook_id.as_str(), + json!({ + "name": "Updated address book", + "description": "My updated personal address book", + "sortOrder": 2, + "isSubscribed": false + }), + )], + [("onSuccessSetIsDefault", addressbook_id.as_str())], + ) + .await + .updated(&addressbook_id); + + // Validate changes + assert_eq!( + account + .jmap_get( + MethodObject::AddressBook, + [ + AddressBookProperty::Id, + AddressBookProperty::Name, + AddressBookProperty::Description, + AddressBookProperty::SortOrder, + AddressBookProperty::IsSubscribed, + AddressBookProperty::IsDefault, + ], + [&addressbook_id, &default_addressbook_id], + ) + .await + .list(), + vec![ + json!({ + "name": "Updated address book", + "description": "My updated personal address book", + "sortOrder": 2, + "isSubscribed": false, + "isDefault": true, + "id": addressbook_id, + }), + json!({ + "name": "Stalwart Address Book (jdoe@example.com)", + "description": (), + "sortOrder": 0, + "isSubscribed": false, + "isDefault": false, + "id": default_addressbook_id, + }) + ] + ); + + // Create a contact + let _ = account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "addressBookIds": { + &addressbook_id: true + }, + "name": { + "components": [ + { "kind": "given", "value": "Joe" }, + { "kind": "surname", "value": "Bloggs" } + ] + }, + "emails": { + "0": { + "address": "joe.bloggs@example.com" + } + } + })], + ) + .await + .created(0) + .id(); + + // Try destroying the address book (should fail) + assert_eq!( + account + .jmap_destroy( + MethodObject::AddressBook, + [&addressbook_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&addressbook_id) + .typ(), + "addressBookHasContents" + ); + + // Destroy using force + assert_eq!( + account + .jmap_destroy( + MethodObject::AddressBook, + [&addressbook_id], + [("onDestroyRemoveContents", true)], + ) + .await + .destroyed() + .collect::>(), + vec![&addressbook_id] + ); + + // Destroy all mailboxes + account.destroy_all_addressbooks().await; + params.assert_is_empty().await; +} diff --git a/tests/src/jmap/contacts/contact.rs b/tests/src/jmap/contacts/contact.rs new file mode 100644 index 00000000..18203525 --- /dev/null +++ b/tests/src/jmap/contacts/contact.rs @@ -0,0 +1,1266 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::{ + jmap::{ChangeType, IntoJmapSet, JMAPTest, JmapUtils}, + webdav::DummyWebDavClient, +}; +use ahash::AHashSet; +use calcard::jscontact::JSContactProperty; +use groupware::cache::GroupwareCache; +use hyper::StatusCode; +use jmap_proto::request::method::MethodObject; +use serde_json::{Value, json}; +use types::{collection::SyncCollection, id::Id}; + +pub async fn test(params: &mut JMAPTest) { + println!("Running contacts tests..."); + let account = params.account("jdoe@example.com"); + + // Create test address books + let response = account + .jmap_create( + MethodObject::AddressBook, + [ + json!({ + "name": "Test #1", + }), + json!({ + "name": "Test #2", + }), + ], + ) + .await; + let book1_id = response.created(0).id().to_string(); + let book2_id = response.created(1).id().to_string(); + + // Obtain state + let change_id = account + .jmap_get( + MethodObject::ContactCard, + Vec::<&str>::new(), + Vec::<&str>::new(), + ) + .await + .state() + .to_string(); + + // Create test contacts + let response = account + .jmap_create( + MethodObject::ContactCard, + [ + test_jscontact_1([book1_id.as_str()]), + test_jscontact_2([book2_id.as_str()]), + test_jscontact_3([book1_id.as_str(), book2_id.as_str()]), + ], + ) + .await; + let sarah_contact_id = response.created(0).id().to_string(); + let carlos_contact_id = response.created(1).id().to_string(); + let acme_contact_id = response.created(2).id().to_string(); + + // Validate changes + assert_eq!( + account + .jmap_changes(MethodObject::ContactCard, &change_id) + .await + .changes() + .collect::>(), + [ + ChangeType::Created(&sarah_contact_id), + ChangeType::Created(&carlos_contact_id), + ChangeType::Created(&acme_contact_id) + ] + .into_iter() + .collect::>(), + ); + + // Fetch contacts and verify + let response = account + .jmap_get( + MethodObject::ContactCard, + [ + JSContactProperty::::Id, + JSContactProperty::AddressBookIds, + JSContactProperty::Name, + ], + [&sarah_contact_id, &carlos_contact_id, &acme_contact_id], + ) + .await; + + assert_eq!( + response.list()[0], + json!({ + "id": &sarah_contact_id, + "name": { + "full": "Sarah Johnson", + "components": [ + { + "kind": "surname", + "value": "Johnson" + }, + { + "kind": "given", + "value": "Sarah" + }, + { + "kind": "given2", + "value": "Marie" + }, + { + "kind": "title", + "value": "Dr." + }, + { + "kind": "credential", + "value": "Ph.D." + } + ], + "isOrdered": true + }, + "addressBookIds": { + &book1_id: true + }, + }) + ); + assert_eq!( + response.list()[1], + json!({ + "id": &carlos_contact_id, + "name": { + "components": [ + { + "kind": "surname", + "value": "Rodriguez-Martinez" + }, + { + "kind": "given", + "value": "Carlos" + }, + { + "kind": "given2", + "value": "Alberto" + }, + { + "kind": "title", + "value": "Mr." + }, + { + "kind": "credential", + "value": "Jr." + } + ], + "isOrdered": true, + "full": "Carlos Rodriguez-Martinez" + }, + "addressBookIds": { + &book2_id: true + }, + }) + ); + assert_eq!( + response.list()[2], + json!({ + "id": acme_contact_id, + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + "name": { + "full": "Acme Business Solutions Ltd." + }, + }) + ); + + // Creating a contact without address book should fail + assert_eq!( + account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "name": { + "full": "Simple Contact", + }, + "addressBookIds": {}, + }),], + ) + .await + .not_created(0) + .description(), + "Contact has to belong to at least one address book." + ); + + // Creating a contact with a duplicate UID should fail + assert!( + account + .jmap_create( + MethodObject::ContactCard, + [json!({ + "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", + "name": { + "full": "Simple Contact", + }, + "addressBookIds": { + &book1_id: true + }, + }),], + ) + .await + .not_created(0) + .description() + .contains( + "Contact with UID urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 already exists" + ), + ); + + // Patching tests + let response = account + .jmap_update( + MethodObject::ContactCard, + [ + ( + &sarah_contact_id, + json!({ + "name/full": "Sarah O'Connor", + "name/components/0/value": "O'Connor", + format!("addressBookIds/{book2_id}"): true + }), + ), + ( + &carlos_contact_id, + json!({ + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + "nicknames/k1": (), + "nicknames/k2": { + "name": "Carlitos" + }, + }), + ), + ( + &acme_contact_id, + json!({ + format!("addressBookIds/{book2_id}"): false, + "keywords/B2B": false, + "keywords/B2C": true, + }), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + response.updated(&sarah_contact_id); + response.updated(&carlos_contact_id); + response.updated(&acme_contact_id); + + // Verify patches + let response = account + .jmap_get( + MethodObject::ContactCard, + [ + JSContactProperty::::Id, + JSContactProperty::AddressBookIds, + JSContactProperty::Name, + JSContactProperty::Keywords, + JSContactProperty::Nicknames, + ], + [&sarah_contact_id, &carlos_contact_id, &acme_contact_id], + ) + .await; + + response.list()[0].assert_is_equal(json!({ + "id": &sarah_contact_id, + "name": { + "full": "Sarah O'Connor", + "components": [ + { + "kind": "surname", + "value": "O'Connor" + }, + { + "kind": "given", + "value": "Sarah" + }, + { + "kind": "given2", + "value": "Marie" + }, + { + "kind": "title", + "value": "Dr." + }, + { + "kind": "credential", + "value": "Ph.D." + } + ], + "isOrdered": true + }, + "nicknames": { + "k1": { + "name": "Sadie" + } + }, + "keywords": { + "Work": true, + "Research": true, + "VIP": true + }, + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + })); + + response.list()[1].assert_is_equal(json!({ + "id": &carlos_contact_id, + "name": { + "components": [ + { + "kind": "surname", + "value": "Rodriguez-Martinez" + }, + { + "kind": "given", + "value": "Carlos" + }, + { + "kind": "given2", + "value": "Alberto" + }, + { + "kind": "title", + "value": "Mr." + }, + { + "kind": "credential", + "value": "Jr." + } + ], + "isOrdered": true, + "full": "Carlos Rodriguez-Martinez" + }, + "keywords": { + "Marketing": true, + "Management": true, + "International": true + }, + "nicknames": { + "k2": { + "name": "Carlitos" + } + }, + "addressBookIds": { + &book1_id: true, + &book2_id: true + }, + })); + + response.list()[2].assert_is_equal(json!({ + "id": acme_contact_id, + "addressBookIds": { + &book1_id: true, + }, + "name": { + "full": "Acme Business Solutions Ltd." + }, + "keywords": { + "Technology": true, + "B2C": true, + "Solutions": true, + "Services": true + } + })); + + // Query tests + assert_eq!( + account + .jmap_query( + MethodObject::ContactCard, + [ + ("text", "Sarah"), + ("inAddressBook", book1_id.as_str()), + ("uid", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + ("email", "sarah.johnson@example.com"), + ], + ["created"], + ) + .await + .ids() + .collect::>(), + [sarah_contact_id.as_str()] + .into_iter() + .collect::>() + ); + + // Parse tests + account + .jmap_method_calls(json!([ + [ + "Blob/upload", + { + "create": { + "vcard": { + "data": [ + { + "data:asText": r#"BEGIN:VCARD +VERSION:4.0 +KIND:individual +FN:Jane Doe +ORG:ABC\, Inc.;North American Division;Marketing +END:VCARD"# + } + ] + } + } + }, + "S4" + ], + [ + "ContactCard/parse", + { + "blobIds": [ + "#vcard" + ] + }, + "G4" + ] + ])) + .await + .pointer("/methodResponses/1/1/parsed") + .unwrap() + .as_object() + .unwrap() + .iter() + .next() + .unwrap() + .1 + .assert_is_equal(json!({ + "name": { + "full": "Jane Doe" + }, + "version": "1.0", + "vCard": { + "properties": [ + [ + "VERSION", + {}, + "unknown", + "4.0" + ] + ] + }, + "organizations": { + "k1": { + "name": "ABC, Inc.", + "units": [ + { + "name": "North American Division" + }, + { + "name": "Marketing" + } + ] + } + }, + "@type": "Card", + "kind": "individual" + })); + + // Deletion tests + assert_eq!( + account + .jmap_destroy( + MethodObject::ContactCard, + [carlos_contact_id.as_str(), acme_contact_id.as_str()], + Vec::<(&str, &str)>::new() + ) + .await + .destroyed() + .collect::>(), + [carlos_contact_id.as_str(), acme_contact_id.as_str()] + .into_iter() + .collect::>() + ); + + // CardDAV compatibility tests + let account_id = account.id().document_id(); + let dav_client = DummyWebDavClient::new( + u32::MAX, + account.name(), + account.secret(), + account.emails()[0], + ); + let resources = params + .server + .fetch_dav_resources( + ¶ms.server.get_access_token(account_id).await.unwrap(), + account_id, + SyncCollection::AddressBook, + ) + .await + .unwrap(); + let path = format!( + "{}{}", + resources.base_path, + resources + .paths + .iter() + .find(|v| v.parent_id.is_some()) + .unwrap() + .path + ); + let vcard = dav_client + .request("GET", &path, "") + .await + .with_status(StatusCode::OK) + .expect_body() + .lines() + .map(String::from) + .collect::>(); + let expected_vcard = TEST_VCARD_1 + .lines() + .map(String::from) + .collect::>(); + assert_eq!(vcard, expected_vcard); + + // Clean up + account.destroy_all_addressbooks().await; + params.assert_is_empty().await; +} + +fn test_jscontact_1(ids: impl IntoJmapSet) -> Value { + json!({ + "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", + "@type": "Card", + "addressBookIds": ids.into_jmap_set(), + "preferredLanguages": { + "k1": { + "language": "en", + "contexts": { + "work": true + }, + "pref": 1 + }, + "k2": { + "language": "fr", + "contexts": { + "work": true + }, + "pref": 2 + } + }, + "name": { + "full": "Sarah Johnson", + "components": [ + { + "kind": "surname", + "value": "Johnson" + }, + { + "kind": "given", + "value": "Sarah" + }, + { + "kind": "given2", + "value": "Marie" + }, + { + "kind": "title", + "value": "Dr." + }, + { + "kind": "credential", + "value": "Ph.D." + } + ] + }, + "cryptoKeys": { + "k1": { + "uri": "https://pgp.example.com/pks/lookup?op=get&search=sarah.johnson@example.com", + "contexts": { + "pgp": true + } + } + }, + "keywords": { + "Work": true, + "Research": true, + "VIP": true + }, + "anniversaries": { + "k1": { + "date": { + "@type": "PartialDate", + "year": 1985, + "month": 4, + "day": 15 + }, + "kind": "birth" + }, + "k2": { + "date": { + "@type": "PartialDate", + "year": 2010, + "month": 6, + "day": 10 + }, + "kind": "wedding" + } + }, + "links": { + "k1": { + "uri": "https://www.example.com/staff/sjohnson", + "contexts": { + "work": true + } + }, + "k2": { + "uri": "https://www.sarahjohnson.example.com", + "contexts": { + "private": true + } + } + }, + "organizations": { + "k1": { + "name": "Acme Technologies Inc.", + "units": [ + { + "name": "Research Department" + } + ] + } + }, + "emails": { + "k1": { + "address": "sarah.johnson@example.com", + "contexts": { + "work": true + } + }, + "k2": { + "address": "sarahjpersonal@example.com", + "contexts": { + "private": true, + "pref": true + } + } + }, + "phones": { + "k1": { + "number": "+1-555-123-4567", + "contexts": { + "pref": true + }, + "features": { + "cell": true, + "voice": true + } + }, + "k2": { + "number": "+1-555-987-6543", + "contexts": { + "work": true + }, + "features": { + "voice": true + } + }, + "k3": { + "number": "+1-555-456-7890", + "contexts": { + "private": true + }, + "features": { + "voice": true + } + } + }, + "version": "1.0", + "addresses": { + "k1": { + "contexts": { + "work": true + }, + "full": "123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA", + "components": [ + { + "kind": "name", + "value": "123 Business Ave" + }, + { + "kind": "locality", + "value": "New York" + }, + { + "kind": "region", + "value": "NY" + }, + { + "kind": "postcode", + "value": "10001" + }, + { + "kind": "country", + "value": "USA" + } + ], + "timeZone": "Etc/GMT+5", + "coordinates": "40.7128;-74.0060" + }, + "k2": { + "contexts": { + "private": true, + "pref": true + }, + "full": "456 Residential St\nApt 7B\nBrooklyn, NY 11201\nUSA", + "components": [ + { + "kind": "name", + "value": "456 Residential St" + }, + { + "kind": "locality", + "value": "Brooklyn" + }, + { + "kind": "region", + "value": "NY" + }, + { + "kind": "postcode", + "value": "11201" + }, + { + "kind": "country", + "value": "USA" + } + ] + } + }, + "titles": { + "k1": { + "name": "Senior Research Scientist", + "kind": "title" + }, + "k2": { + "name": "Team Lead", + "kind": "role", + "organizationId": "k1" + } + }, + "nicknames": { + "k1": { + "name": "Sadie" + } + }, + "notes": { + "k1": { + "note": "Sarah prefers video calls over phone calls. Available Mon-Thu 9-5 EST." + } + }, + "updated": "2022-03-15T13:30:00Z" + }) +} + +fn test_jscontact_2(ids: impl IntoJmapSet) -> Value { + json!({ + "addressBookIds": ids.into_jmap_set(), + "phones": { + "k1": { + "number": "+34-611-234-567", + "contexts": { + "pref": true + }, + "features": { + "cell": true, + "voice": true + } + }, + "k2": { + "number": "+34-911-876-543", + "contexts": { + "work": true + }, + "features": { + "voice": true + } + }, + "k3": { + "number": "+34-644-321-987", + "contexts": { + "private": true + }, + "features": { + "voice": true + } + }, + "k4": { + "number": "+34-911-876-544", + "features": { + "fax": true + } + } + }, + "keywords": { + "Marketing": true, + "Management": true, + "International": true + }, + "kind": "individual", + "anniversaries": { + "k1": { + "date": { + "@type": "PartialDate", + "month": 6, + "day": 23 + }, + "kind": "birth" + }, + "k2": { + "date": { + "@type": "PartialDate", + "year": 2015, + "month": 8, + "day": 9 + }, + "kind": "wedding" + } + }, + "members": { + "urn:uuid:03a0e51f-d1aa-4385-8a53-e29025acd8af": true + }, + "uid": "urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686a", + "name": { + "components": [ + { + "kind": "surname", + "value": "Rodriguez-Martinez" + }, + { + "kind": "given", + "value": "Carlos" + }, + { + "kind": "given2", + "value": "Alberto" + }, + { + "kind": "title", + "value": "Mr." + }, + { + "kind": "credential", + "value": "Jr." + } + ], + "full": "Carlos Rodriguez-Martinez" + }, + "nicknames": { + "k1": { + "name": "Charlie" + } + }, + "relatedTo": { + "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": { + "relation": { + "friend": true + } + } + }, + "emails": { + "k1": { + "address": "carlos.rodriguez@example-corp.com", + "contexts": { + "work": true, + "pref": true + } + }, + "k2": { + "address": "carlosrm@personalmail.example", + "contexts": { + "private": true + } + } + }, + "directories": { + "k1": { + "uri": "https://contacts.example.com/carlosrodriguez.vcf", + "kind": "entry" + } + }, + "cryptoKeys": { + "k1": { + "uri": "https://pgp.example.com/pks/lookup?op=get&search=carlos.rodriguez@example-corp.com", + "contexts": { + "pgp": true + } + } + }, + "version": "1.0", + "notes": { + "k1": { + "note": "Carlos speaks English, Spanish, and Portuguese fluently. Prefers communication via email. Do not contact after 7PM CET." + } + }, + "updated": "2023-07-12T09:21:35Z", + "links": { + "k1": { + "uri": "https://www.example-corp.com/team/carlos", + "contexts": { + "work": true + } + }, + "k2": { + "uri": "https://www.carlosrodriguez.example", + "contexts": { + "private": true + } + }, + "k3": { + "uri": "https://linkedin.com/in/carlosrodriguezm", + "contexts": { + "social": true + } + } + }, + "@type": "Card", + "titles": { + "k1": { + "name": "Digital Marketing Director", + "kind": "title" + }, + "k2": { + "name": "Department Head", + "kind": "role", + "organizationId": "k1" + } + }, + "preferredLanguages": { + "k1": { + "language": "es", + "contexts": { + "work": true + }, + "pref": 1 + }, + "k2": { + "language": "en", + "contexts": { + "work": true + }, + "pref": 2 + }, + "k3": { + "language": "pt", + "contexts": { + "work": true + }, + "pref": 3 + } + }, + "addresses": { + "k1": { + "contexts": { + "work": true + }, + "full": "Calle Empresarial 42\nPlanta 3\nMadrid, 28001\nSpain", + "components": [ + { + "kind": "name", + "value": "Calle Empresarial 42" + }, + { + "kind": "locality", + "value": "Madrid" + }, + { + "kind": "postcode", + "value": "28001" + }, + { + "kind": "country", + "value": "Spain" + } + ], + "timeZone": "Etc/GMT-1", + "coordinates": "40.4168;-3.7038" + }, + "k2": { + "contexts": { + "private": true, + "pref": true + }, + "full": "Avenida Residencial 15\nPiso 7, Puerta C\nMadrid, 28045\nSpain", + "components": [ + { + "kind": "name", + "value": "Avenida Residencial 15" + }, + { + "kind": "locality", + "value": "Madrid" + }, + { + "kind": "postcode", + "value": "28045" + }, + { + "kind": "country", + "value": "Spain" + } + ] + } + }, + "organizations": { + "k1": { + "name": "Global Solutions S.L.", + "units": [ + { + "name": "Marketing Division" + } + ] + } + } + }) +} + +fn test_jscontact_3(ids: impl IntoJmapSet) -> Value { + json!({ + "addressBookIds": ids.into_jmap_set(), + "kind": "org", + "organizations": { + "k1": { + "name": "Acme Business Solutions Ltd.", + "units": [ + { + "name": "Technology Division" + } + ] + } + }, + "preferredLanguages": { + "k1": { + "language": "en", + "contexts": { + "work": true + }, + "pref": 1 + }, + "k2": { + "language": "de", + "contexts": { + "work": true + }, + "pref": 2 + }, + "k3": { + "language": "fr", + "contexts": { + "work": true + }, + "pref": 3 + } + }, + "directories": { + "k1": { + "uri": "https://directory.example.com/acme.vcf", + "kind": "entry" + } + }, + "cryptoKeys": { + "k1": { + "uri": "https://pgp.example.com/pks/lookup?op=get&search=info@acme-solutions.example", + "contexts": { + "pgp": true + } + } + }, + "links": { + "k1": { + "uri": "https://www.acme-solutions.example", + "contexts": { + "work": true + } + }, + "k2": { + "uri": "https://support.acme-solutions.example", + "contexts": { + "support": true + } + } + }, + "name": { + "full": "Acme Business Solutions Ltd.", + "components": [] + }, + "notes": { + "k1": { + "note": "Business hours: Mon-Fri 9:00-17:30 GMT. Closed on UK bank holidays. VAT Reg: GB123456789" + } + }, + "uid": "urn:uuid:a9e95948-7b1c-46e8-bd85-c729a9e910f2", + "@type": "Card", + "prodId": "-//Example Corp.//Contact Manager 3.0//EN", + "version": "1.0", + "emails": { + "k1": { + "address": "info@acme-solutions.example", + "contexts": { + "work": true, + "pref": true + } + }, + "k2": { + "address": "support@acme-solutions.example", + "contexts": { + "support": true + } + }, + "k3": { + "address": "sales@acme-solutions.example", + "contexts": { + "sales": true + } + } + }, + "phones": { + "k1": { + "number": "+44-20-1234-5678", + "contexts": { + "work": true, + "pref": true + }, + "features": { + "voice": true + } + }, + "k2": { + "number": "+44-20-1234-5679", + "features": { + "fax": true + } + }, + "k3": { + "number": "+44-800-987-6543", + "contexts": { + "support": true + } + } + }, + "addresses": { + "k1": { + "contexts": { + "work": true + }, + "full": "10 Enterprise Way\nTech Park\nLondon, EC1A 1BB\nUnited Kingdom", + "components": [ + { + "kind": "name", + "value": "10 Enterprise Way, Tech Park" + }, + { + "kind": "locality", + "value": "London" + }, + { + "kind": "postcode", + "value": "EC1A 1BB" + }, + { + "kind": "country", + "value": "United Kingdom" + } + ], + "timeZone": "Etc/UTC", + "coordinates": "51.5074;-0.1278" + }, + "k2": { + "contexts": { + "branch": true + }, + "full": "25 Innovation Street\nManchester, M1 5QF\nUnited Kingdom", + "components": [ + { + "kind": "name", + "value": "25 Innovation Street" + }, + { + "kind": "locality", + "value": "Manchester" + }, + { + "kind": "postcode", + "value": "M1 5QF" + }, + { + "kind": "country", + "value": "United Kingdom" + } + ] + } + }, + "updated": "2023-04-15T15:30:00Z", + "keywords": { + "Technology": true, + "B2B": true, + "Solutions": true, + "Services": true + }, + "relatedTo": { + "urn:uuid:b9e93fdb-4d34-45fa-a1e2-47da0428c4a1": { + "relation": { + "contact": true + } + }, + "urn:uuid:c8e74dfe-6b34-45fa-b1e2-47ea0428c4b2": { + "relation": { + "contact": true + } + } + } + }) +} + +const TEST_VCARD_1: &str = r#"BEGIN:VCARD +VERSION:4.0 +UID:urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 +LANG;TYPE=WORK;PREF=1;PROP-ID=k1:en +LANG;TYPE=WORK;PREF=2;PROP-ID=k2:fr +FN:Sarah O'Connor +N;JSCOMPS=";0;1;2;3;4":O'Connor;Sarah;Marie;Dr.;Ph.D.;; +KEY;TYPE=pgp;PROP-ID=k1:https://pgp.example.com/pks/lookup?op=get&search=sar + ah.johnson@example.com +CATEGORIES:Work,Research,VIP +BDAY;PROP-ID=k1:19850415 +ANNIVERSARY;PROP-ID=k2:20100610 +URL;TYPE=WORK;PROP-ID=k1:https://www.example.com/staff/sjohnson +URL;TYPE=HOME;PROP-ID=k2:https://www.sarahjohnson.example.com +ORG;PROP-ID=k1:Acme Technologies Inc.;Research Department +EMAIL;TYPE=WORK;PROP-ID=k1:sarah.johnson@example.com +EMAIL;TYPE=HOME,pref;PROP-ID=k2:sarahjpersonal@example.com +TEL;TYPE=pref,CELL,VOICE;PROP-ID=k1:+1-555-123-4567 +TEL;TYPE=WORK,VOICE;PROP-ID=k2:+1-555-987-6543 +TEL;TYPE=HOME,VOICE;PROP-ID=k3:+1-555-456-7890 +ADR;TYPE=WORK;LABEL="123 Business Ave\nSuite 400\nNew York, NY 10001\nUSA"; + TZ=Etc/GMT+5;GEO="40.7128;-74.0060";PROP-ID=k1;JSCOMPS=";11;3;4;5;6":;;123 B + usiness Ave;New York;NY;10001;USA;;;;;123 Business Ave;;;;;; +ADR;TYPE=HOME,pref;LABEL="456 Residential St\nApt 7B\nBrooklyn, NY 11201\nU + SA";PROP-ID=k2;JSCOMPS=";11;3;4;5;6":;;456 Residential St;Brooklyn;NY;11201; + USA;;;;;456 Residential St;;;;;; +TITLE;PROP-ID=k1:Senior Research Scientist +JSPROP;JSPTR=titles/k2/organizationId:"k1" +ROLE;PROP-ID=k2:Team Lead +NICKNAME;PROP-ID=k1:Sadie +NOTE;PROP-ID=k1:Sarah prefers video calls over phone calls. Available Mon-Th + u 9-5 EST. +REV:20220315T133000Z +END:VCARD +"#; diff --git a/tests/src/jmap/contacts/mod.rs b/tests/src/jmap/contacts/mod.rs index e63be942..d44c4747 100644 --- a/tests/src/jmap/contacts/mod.rs +++ b/tests/src/jmap/contacts/mod.rs @@ -4,4 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ - \ No newline at end of file +pub mod acl; +pub mod addressbook; +pub mod contact; diff --git a/tests/src/jmap/core/blob.rs b/tests/src/jmap/core/blob.rs index 1ec84b94..0af56c53 100644 --- a/tests/src/jmap/core/blob.rs +++ b/tests/src/jmap/core/blob.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty}; +use crate::jmap::JMAPTest; use email::mailbox::INBOX_ID; use serde_json::{Value, json}; use types::id::Id; @@ -412,5 +412,5 @@ pub async fn test(params: &mut JMAPTest) { // Remove test data params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/core/event_source.rs b/tests/src/jmap/core/event_source.rs index a4cae537..ddb3403f 100644 --- a/tests/src/jmap/core/event_source.rs +++ b/tests/src/jmap/core/event_source.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty, mail::delivery::SmtpConnection}; +use crate::jmap::{JMAPTest, mail::delivery::SmtpConnection}; use email::mailbox::INBOX_ID; use futures::StreamExt; use jmap_client::{TypeState, event_source::Changes, mailbox::Role}; @@ -17,7 +17,6 @@ pub async fn test(params: &mut JMAPTest) { println!("Running EventSource tests..."); // Create test account - let server = params.server.clone(); let account = params.account("jdoe@example.com"); let client = account.client(); @@ -106,7 +105,7 @@ pub async fn test(params: &mut JMAPTest) { assert_ping(&mut event_rx).await; params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.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 1d097e58..7a4c9c25 100644 --- a/tests/src/jmap/core/push_subscription.rs +++ b/tests/src/jmap/core/push_subscription.rs @@ -4,10 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{ - AssertConfig, add_test_certs, - jmap::{JMAPTest, assert_is_empty}, -}; +use crate::{AssertConfig, add_test_certs, jmap::JMAPTest}; use base64::{Engine, engine::general_purpose}; use common::{Caches, Core, Data, Inner, config::server::Listeners, listener::SessionData}; use ece::EcKeyComponents; @@ -54,7 +51,6 @@ pub async fn test(params: &mut JMAPTest) { println!("Running Push Subscription tests..."); // Create test account - let server = params.server.clone(); let account = params.account("jdoe@example.com"); let client = account.client(); @@ -205,7 +201,7 @@ pub async fn test(params: &mut JMAPTest) { expect_nothing(&mut event_rx).await; params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } #[derive(Clone)] diff --git a/tests/src/jmap/core/websocket.rs b/tests/src/jmap/core/websocket.rs index 459d79b6..4af0a5c7 100644 --- a/tests/src/jmap/core/websocket.rs +++ b/tests/src/jmap/core/websocket.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty}; +use crate::jmap::JMAPTest; use ahash::AHashSet; use futures::StreamExt; use jmap_client::{ @@ -20,7 +20,6 @@ use tokio::sync::mpsc; pub async fn test(params: &mut JMAPTest) { println!("Running WebSockets tests..."); - let server = params.server.clone(); // Authenticate all accounts let account = params.account("jdoe@example.com"); @@ -98,7 +97,7 @@ pub async fn test(params: &mut JMAPTest) { expect_nothing(&mut stream_rx).await; params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } async fn expect_response( diff --git a/tests/src/jmap/files/acl.rs b/tests/src/jmap/files/acl.rs new file mode 100644 index 00000000..f6336379 --- /dev/null +++ b/tests/src/jmap/files/acl.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); +} diff --git a/tests/src/jmap/files/mod.rs b/tests/src/jmap/files/mod.rs index 69b01f91..96aff776 100644 --- a/tests/src/jmap/files/mod.rs +++ b/tests/src/jmap/files/mod.rs @@ -3,3 +3,6 @@ * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ + +pub mod acl; +pub mod node; diff --git a/tests/src/jmap/files/node.rs b/tests/src/jmap/files/node.rs new file mode 100644 index 00000000..f6336379 --- /dev/null +++ b/tests/src/jmap/files/node.rs @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::request::method::MethodObject; +use serde_json::json; + +pub async fn test(params: &mut JMAPTest) { + println!("Running tests..."); + let account = params.account("jdoe@example.com"); +} diff --git a/tests/src/jmap/mail/acl.rs b/tests/src/jmap/mail/acl.rs index 2783e883..28dd44af 100644 --- a/tests/src/jmap/mail/acl.rs +++ b/tests/src/jmap/mail/acl.rs @@ -6,7 +6,7 @@ use crate::{ directory::internal::TestInternalDirectory, - jmap::{JMAPTest, assert_is_empty}, + jmap::{JMAPTest}, }; use ::email::mailbox::{INBOX_ID, TRASH_ID}; use jmap_client::{ @@ -721,7 +721,7 @@ pub async fn test(params: &mut JMAPTest) { for id in [john, bill, jane, sales] { params.destroy_all_mailboxes(id).await; } - assert_is_empty(server).await; + params.assert_is_empty().await; } pub fn assert_forbidden(result: Result) { diff --git a/tests/src/jmap/mail/changes.rs b/tests/src/jmap/mail/changes.rs index 947dacb3..caabb8c7 100644 --- a/tests/src/jmap/mail/changes.rs +++ b/tests/src/jmap/mail/changes.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty}; +use crate::jmap::{JMAPTest}; use jmap_proto::types::state::State; use std::str::FromStr; use store::{ahash::AHashSet, write::BatchBuilder}; @@ -312,7 +312,7 @@ pub async fn test(params: &mut JMAPTest) { assert_eq!(created, vec![2, 3, 11, 12]); assert_eq!(changes.updated(), Vec::::new()); assert_eq!(changes.destroyed(), Vec::::new()); - assert_is_empty(server).await; + params.assert_is_empty().await; } #[derive(Debug, Clone, Copy)] diff --git a/tests/src/jmap/mail/copy.rs b/tests/src/jmap/mail/copy.rs index d24b1c81..d930db4c 100644 --- a/tests/src/jmap/mail/copy.rs +++ b/tests/src/jmap/mail/copy.rs @@ -4,13 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty, mail::mailbox::destroy_all_mailboxes_for_account}; +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) { println!("Running Email Copy tests..."); - let server = params.server.clone(); let account = params.account("admin"); let mut client = account.client_owned().await; @@ -99,5 +98,5 @@ pub async fn test(params: &mut JMAPTest) { // Empty store destroy_all_mailboxes_for_account(1).await; destroy_all_mailboxes_for_account(2).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/mail/delivery.rs b/tests/src/jmap/mail/delivery.rs index 9284b4c1..2dbc5296 100644 --- a/tests/src/jmap/mail/delivery.rs +++ b/tests/src/jmap/mail/delivery.rs @@ -6,7 +6,7 @@ use crate::{ directory::internal::TestInternalDirectory, - jmap::{JMAPTest, assert_is_empty}, + jmap::{JMAPTest}, webdav::DummyWebDavClient, }; use email::{ @@ -32,9 +32,7 @@ pub async fn test(params: &mut JMAPTest) { // Create a mailing list server - .core - .storage - .data + .store() .create_test_list( "members@example.com", "Mailing List", @@ -116,8 +114,7 @@ pub async fn test(params: &mut JMAPTest) { assert_eq!(john_cache.in_mailbox(JUNK_ID).count(), 1); // CardDAV spam override - let dav_client = - DummyWebDavClient::new(u32::MAX, "jdoe@example.com", "12345", "jdoe@example.com"); + let dav_client = DummyWebDavClient::new(u32::MAX, john.name(), john.secret(), john.emails()[0]); dav_client .request( "PUT", @@ -285,7 +282,7 @@ END:VCARD for account in [john, jane, bill] { params.destroy_all_mailboxes(account).await; } - assert_is_empty(server).await; + params.assert_is_empty().await; // Check webhook events params.webhook.assert_contains(&[ diff --git a/tests/src/jmap/mail/get.rs b/tests/src/jmap/mail/get.rs index 448cae4e..fd4e59ad 100644 --- a/tests/src/jmap/mail/get.rs +++ b/tests/src/jmap/mail/get.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty, replace_blob_ids}; +use crate::jmap::{JMAPTest, replace_blob_ids}; use ::email::mailbox::INBOX_ID; use jmap_client::email::{self, Header, HeaderForm, import::EmailImportResponse}; use mail_parser::HeaderName; @@ -13,7 +13,6 @@ use types::id::Id; pub async fn test(params: &mut JMAPTest) { println!("Running Email Get tests..."); - let server = params.server.clone(); let mut test_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); test_dir.push("resources"); @@ -167,7 +166,7 @@ pub async fn test(params: &mut JMAPTest) { } params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } pub fn all_headers() -> Vec { diff --git a/tests/src/jmap/mail/mailbox.rs b/tests/src/jmap/mail/mailbox.rs index 981239de..5fde7804 100644 --- a/tests/src/jmap/mail/mailbox.rs +++ b/tests/src/jmap/mail/mailbox.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{Account, JMAPTest, assert_is_empty, wait_for_index}; +use crate::jmap::{Account, JMAPTest, wait_for_index}; use jmap_client::{ Error, Set, client::{Client, Credentials}, @@ -22,7 +22,6 @@ use types::id::Id; pub async fn test(params: &mut JMAPTest) { println!("Running Mailbox tests..."); - let server = params.server.clone(); let account = params.account("admin"); let mut client = account.client_owned().await; @@ -609,7 +608,7 @@ pub async fn test(params: &mut JMAPTest) { ); destroy_all_mailboxes_no_wait(&client).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } async fn create_test_mailboxes(client: &Client) -> AHashMap { diff --git a/tests/src/jmap/mail/parse.rs b/tests/src/jmap/mail/parse.rs index b9fa37c1..a9e7b7fa 100644 --- a/tests/src/jmap/mail/parse.rs +++ b/tests/src/jmap/mail/parse.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty, mail::get::all_headers, replace_blob_ids}; +use crate::jmap::{JMAPTest, mail::get::all_headers, replace_blob_ids}; use jmap_client::{ email::{self, Header, HeaderForm}, mailbox::Role, @@ -13,7 +13,6 @@ use std::{fs, path::PathBuf}; pub async fn test(params: &mut JMAPTest) { println!("Running Email Parse tests..."); - let server = params.server.clone(); let account = params.account("jdoe@example.com"); let client = account.client(); @@ -223,5 +222,5 @@ pub async fn test(params: &mut JMAPTest) { } params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/mail/query.rs b/tests/src/jmap/mail/query.rs index b4f53a0f..95090f6e 100644 --- a/tests/src/jmap/mail/query.rs +++ b/tests/src/jmap/mail/query.rs @@ -5,7 +5,7 @@ */ use crate::{ - jmap::{JMAPTest, assert_is_empty, wait_for_index}, + jmap::{JMAPTest, wait_for_index}, store::{deflate_test_resource, query::FIELDS}, }; use ::email::{cache::MessageCacheFetch, mailbox::Mailbox}; @@ -107,7 +107,7 @@ pub async fn test(params: &mut JMAPTest, insert: bool) { .unwrap(); params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } pub async fn query(client: &Client) { diff --git a/tests/src/jmap/mail/query_changes.rs b/tests/src/jmap/mail/query_changes.rs index 3e6f879f..94f431b4 100644 --- a/tests/src/jmap/mail/query_changes.rs +++ b/tests/src/jmap/mail/query_changes.rs @@ -5,7 +5,7 @@ */ use crate::jmap::{ - JMAPTest, assert_is_empty, + JMAPTest, mail::changes::{LogAction, ParseState}, }; use ::email::message::metadata::MessageData; @@ -277,7 +277,7 @@ pub async fn test(params: &mut JMAPTest) { } params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } #[derive(Debug, Clone)] diff --git a/tests/src/jmap/mail/search_snippet.rs b/tests/src/jmap/mail/search_snippet.rs index 4bc1d0e8..da913f58 100644 --- a/tests/src/jmap/mail/search_snippet.rs +++ b/tests/src/jmap/mail/search_snippet.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty, wait_for_index}; +use crate::jmap::{JMAPTest, wait_for_index}; use email::mailbox::INBOX_ID; use jmap_client::{core::query, email::query::Filter}; use std::{fs, path::PathBuf}; @@ -164,5 +164,5 @@ pub async fn test(params: &mut JMAPTest) { // Destroy test data params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/mail/set.rs b/tests/src/jmap/mail/set.rs index 285e62d3..6b27424c 100644 --- a/tests/src/jmap/mail/set.rs +++ b/tests/src/jmap/mail/set.rs @@ -4,9 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{ - JMAPTest, assert_is_empty, find_values, replace_blob_ids, replace_boundaries, replace_values, -}; +use crate::jmap::{JMAPTest, find_values, replace_blob_ids, replace_boundaries, replace_values}; use ::email::mailbox::INBOX_ID; use ahash::AHashSet; use jmap_client::{ @@ -21,7 +19,6 @@ use types::id::Id; pub async fn test(params: &mut JMAPTest) { println!("Running Email Set tests..."); - let server = params.server.clone(); let account = params.account("jdoe@example.com"); let client = account.client(); let mailbox_id = Id::from(INBOX_ID).to_string(); @@ -30,7 +27,7 @@ pub async fn test(params: &mut JMAPTest) { update(client, &mailbox_id).await; params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } async fn create(client: &Client, mailbox_id: &str) { diff --git a/tests/src/jmap/mail/sieve_script.rs b/tests/src/jmap/mail/sieve_script.rs index 7d73210e..0b8677e8 100644 --- a/tests/src/jmap/mail/sieve_script.rs +++ b/tests/src/jmap/mail/sieve_script.rs @@ -6,7 +6,7 @@ use crate::{ jmap::{ - JMAPTest, assert_is_empty, + JMAPTest, mail::{ delivery::SmtpConnection, submission::{MockMessage, assert_message_delivery, spawn_mock_smtp_server}, @@ -497,7 +497,7 @@ pub async fn test(params: &mut JMAPTest) { client.sieve_script_destroy(&id).await.unwrap(); } params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } fn get_script(name: &str) -> Vec { diff --git a/tests/src/jmap/mail/submission.rs b/tests/src/jmap/mail/submission.rs index 0e41b389..aa704838 100644 --- a/tests/src/jmap/mail/submission.rs +++ b/tests/src/jmap/mail/submission.rs @@ -5,7 +5,7 @@ */ use crate::{ - jmap::{JMAPTest, assert_is_empty, mail::set::assert_email_properties}, + jmap::{JMAPTest, mail::set::assert_email_properties}, smtp::DnsCache, }; use ahash::AHashMap; @@ -477,7 +477,7 @@ pub async fn test(params: &mut JMAPTest) { client.email_submission_destroy(&id).await.unwrap(); } params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } pub fn spawn_mock_smtp_server() -> (mpsc::Receiver, Arc>) { diff --git a/tests/src/jmap/mail/thread_get.rs b/tests/src/jmap/mail/thread_get.rs index 1470bc0d..b854e416 100644 --- a/tests/src/jmap/mail/thread_get.rs +++ b/tests/src/jmap/mail/thread_get.rs @@ -4,12 +4,11 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, assert_is_empty}; +use crate::jmap::JMAPTest; use jmap_client::mailbox::Role; pub async fn test(params: &mut JMAPTest) { println!("Running Email Thread tests..."); - let server = params.server.clone(); let account = params.account("jdoe@example.com"); let client = account.client(); @@ -47,5 +46,5 @@ pub async fn test(params: &mut JMAPTest) { ); params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/mail/thread_merge.rs b/tests/src/jmap/mail/thread_merge.rs index 0df21590..e16932bd 100644 --- a/tests/src/jmap/mail/thread_merge.rs +++ b/tests/src/jmap/mail/thread_merge.rs @@ -5,7 +5,7 @@ */ use crate::{ - jmap::{JMAPTest, assert_is_empty, mail::mailbox::destroy_all_mailboxes_no_wait}, + jmap::{JMAPTest, mail::mailbox::destroy_all_mailboxes_no_wait}, store::deflate_test_resource, }; use ::email::{ @@ -29,7 +29,6 @@ pub async fn test(params: &mut JMAPTest) { async fn test_single_thread(params: &mut JMAPTest) { println!("Running Email Merge Threads tests..."); - let server = params.server.clone(); let account = params.account("admin"); let mut client = account.client_owned().await; let mut all_mailboxes = AHashMap::default(); @@ -204,7 +203,7 @@ async fn test_single_thread(params: &mut JMAPTest) { } } - assert_is_empty(server).await; + params.assert_is_empty().await; } #[allow(dead_code)] @@ -275,7 +274,7 @@ async fn test_multi_thread(params: &mut JMAPTest) { ); println!("Deleting all messages..."); params.destroy_all_mailboxes(account).await; - assert_is_empty(params.server.clone()).await; + params.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 7ab88243..ece0f5be 100644 --- a/tests/src/jmap/mail/vacation_response.rs +++ b/tests/src/jmap/mail/vacation_response.rs @@ -6,7 +6,7 @@ use crate::{ jmap::{ - JMAPTest, assert_is_empty, + JMAPTest, mail::{ delivery::SmtpConnection, submission::{ @@ -162,5 +162,5 @@ pub async fn test(params: &mut JMAPTest) { // Remove test data client.vacation_response_destroy().await.unwrap(); params.destroy_all_mailboxes(account).await; - assert_is_empty(server).await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index cf16e390..cb4031bb 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -77,8 +77,8 @@ async fn jmap_tests() { ) .await; - server::webhooks::test(&mut params).await; - /*mail::query::test(&mut params, delete).await; + /*server::webhooks::test(&mut params).await; + mail::query::test(&mut params, delete).await; mail::get::test(&mut params).await; mail::set::test(&mut params).await; mail::parse::test(&mut params).await; @@ -100,11 +100,15 @@ async fn jmap_tests() { mail::submission::test(&mut params).await; core::websocket::test(&mut params).await; auth::quota::test(&mut params).await; - mail::crypto::test(&mut params).await;*/ + mail::crypto::test(&mut params).await; core::blob::test(&mut params).await; auth::permissions::test(¶ms).await; server::purge::test(&mut params).await; - server::enterprise::test(&mut params).await; + server::enterprise::test(&mut params).await;*/ + + //contacts::addressbook::test(&mut params).await; + //contacts::contact::test(&mut params).await; + contacts::acl::test(&mut params).await; if delete { params.temp_dir.delete(); @@ -146,6 +150,10 @@ impl JMAPTest { pub fn account(&self, name: &str) -> &Account { self.accounts.get(name).unwrap() } + + pub async fn assert_is_empty(&self) { + assert_is_empty(&self.server).await; + } } impl Account { @@ -161,14 +169,14 @@ impl Account { &self.client } - pub fn name(&self) -> &str { + pub fn name(&self) -> &'static str { self.name } - pub fn secret(&self) -> &str { + pub fn secret(&self) -> &'static str { self.secret } - pub fn emails(&self) -> &[&str] { + pub fn emails(&self) -> &'static [&'static str] { self.emails } @@ -230,9 +238,9 @@ pub async fn wait_for_index(server: &Server) { } } -pub async fn assert_is_empty(server: Server) { +pub async fn assert_is_empty(server: &Server) { // Wait for pending FTS index tasks - wait_for_index(&server).await; + wait_for_index(server).await; // Delete bayes model server @@ -242,17 +250,23 @@ pub async fn assert_is_empty(server: Server) { .unwrap(); // Purge accounts - emails_purge_tombstoned(&server).await; + emails_purge_tombstoned(server).await; // Assert is empty server - .core - .storage - .data + .store() .assert_is_empty(server.core.storage.blob.clone()) .await; - // Clean cache + // Clean caches + for cache in [ + &server.inner.cache.events, + &server.inner.cache.contacts, + &server.inner.cache.files, + &server.inner.cache.scheduling, + ] { + cache.clear(); + } server.inner.cache.messages.clear(); } @@ -507,6 +521,211 @@ async fn init_jmap_tests(store_id: &str, delete_if_exists: bool) -> JMAPTest { pub struct JmapResponse(pub Value); impl Account { + pub async fn jmap_get( + &self, + object: impl Display, + properties: impl IntoIterator, + ids: impl IntoIterator, + ) -> JmapResponse { + self.jmap_get_account(self, object, properties, ids).await + } + + pub async fn jmap_get_account( + &self, + account: &Account, + object: impl Display, + properties: impl IntoIterator, + ids: impl IntoIterator, + ) -> JmapResponse { + let ids = ids + .into_iter() + .map(|id| Value::String(id.to_string())) + .collect::>(); + self.jmap_method_calls(json!([[ + format!("{object}/get"), + { + "accountId": account.id_string(), + "properties": properties + .into_iter() + .map(|p| Value::String(p.to_string())) + .collect::>(), + "ids": if !ids.is_empty() { Some(ids) } else { None } + }, + "0" + ]])) + .await + } + + pub async fn jmap_query( + &self, + object: impl Display, + filter: impl IntoIterator)>, + sort_by: impl IntoIterator, + ) -> JmapResponse { + let filter = filter + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())) + .collect::>(); + let sort_by = sort_by + .into_iter() + .map(|id| { + json! ({ + "property": id.to_string() + }) + }) + .collect::>(); + self.jmap_method_calls(json!([[ + format!("{object}/query"), + { + "filter": filter, + "sort": sort_by + }, + "0" + ]])) + .await + } + + pub async fn jmap_create( + &self, + object: impl Display, + items: impl IntoIterator, + ) -> JmapResponse { + self.jmap_create_account(self, object, items).await + } + + pub async fn jmap_create_account( + &self, + account: &Account, + object: impl Display, + items: impl IntoIterator, + ) -> JmapResponse { + self.jmap_method_calls(json!([[ + format!("{object}/set"), + { + "accountId": account.id_string(), + "create": items.into_iter().enumerate().map(|(i, item)| { + (format!("i{i}"), item) + }).collect::>() + }, + "0" + ]])) + .await + } + + pub async fn jmap_update( + &self, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + self.jmap_update_account(self, object, items, arguments) + .await + } + + pub async fn jmap_update_account( + &self, + account: &Account, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + let update = items + .into_iter() + .map(|(i, item)| (i.to_string(), item)) + .collect::>(); + let arguments = [ + ( + "accountId".to_string(), + Value::String(account.id_string().to_string()), + ), + ("update".to_string(), Value::Object(update)), + ] + .into_iter() + .chain( + arguments + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())), + ) + .collect::>(); + + self.jmap_method_calls(json!([[format!("{object}/set"), arguments, "0"]])) + .await + } + + pub async fn jmap_destroy( + &self, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + self.jmap_destroy_account(self, object, items, arguments) + .await + } + + pub async fn jmap_destroy_account( + &self, + account: &Account, + object: impl Display, + items: impl IntoIterator, + arguments: impl IntoIterator)>, + ) -> JmapResponse { + let destroy = items + .into_iter() + .map(|id| Value::String(id.to_string())) + .collect::>(); + let arguments = [ + ( + "accountId".to_string(), + Value::String(account.id_string().to_string()), + ), + ("destroy".to_string(), Value::Array(destroy)), + ] + .into_iter() + .chain( + arguments + .into_iter() + .map(|(k, v)| (k.to_string(), v.into())), + ) + .collect::>(); + + self.jmap_method_calls(json!([[format!("{object}/set"), arguments, "0"]])) + .await + } + + pub async fn jmap_copy( + &self, + from_account: &Account, + to_account: &Account, + object: impl Display, + items: impl IntoIterator, + on_success_destroy: bool, + ) -> JmapResponse { + self.jmap_method_calls(json!([[ + format!("{object}/copy"), + { + "fromAccountId": from_account.id_string(), + "accountId": to_account.id_string(), + "onSuccessDestroyOriginal": on_success_destroy, + "create": items + .into_iter() + .map(|(i, item)| (i.to_string(), item)).collect::>() + }, + "0" + ]])) + .await + } + + pub async fn jmap_changes(&self, object: impl Display, state: impl Display) -> JmapResponse { + self.jmap_method_calls(json!([[ + format!("{object}/changes"), + { + "sinceState": state.to_string() + }, + "0" + ]])) + .await + } + pub async fn jmap_method_call(&self, method_name: &str, body: Value) -> JmapResponse { self.jmap_method_calls(json!([[method_name, body, "0"]])) .await @@ -549,9 +768,142 @@ impl Account { .unwrap(), ) } + + pub async fn destroy_all_addressbooks(&self) { + self.jmap_method_calls(json!([[ + "AddressBook/get", + { + "ids" : (), + "properties" : [ + "id" + ] + }, + "R1" + ], + [ + "AddressBook/set", + { + "#destroy" : { + "resultOf": "R1", + "name": "AddressBook/get", + "path": "/list/*/id" + }, + "onDestroyRemoveContents" : true + }, + "R2" + ] + ])) + .await; + } } impl JmapResponse { + pub fn created(&self, item_idx: u32) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/created/i{item_idx}")) + .unwrap_or_else(|| panic!("Missing created item {item_idx}: {self:?}")) + } + + pub fn not_created(&self, item_idx: u32) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/notCreated/i{item_idx}")) + .unwrap_or_else(|| panic!("Missing not created item {item_idx}: {self:?}")) + } + + pub fn updated(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/updated/{id}")) + .unwrap_or_else(|| panic!("Missing updated item {id}: {self:?}")) + } + + pub fn not_updated(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/notUpdated/{id}")) + .unwrap_or_else(|| panic!("Missing not updated item {id}: {self:?}")) + } + + pub fn copied(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/created/{id}")) + .unwrap_or_else(|| panic!("Missing updated item {id}: {self:?}")) + } + + pub fn method_response(&self) -> &Value { + self.0 + .pointer("/methodResponses/0/1") + .unwrap_or_else(|| panic!("Missing method response in response: {self:?}")) + } + + pub fn list(&self) -> &[Value] { + self.0 + .pointer("/methodResponses/0/1/list") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing list in response: {self:?}")) + } + + pub fn not_found(&self) -> impl Iterator { + self.0 + .pointer("/methodResponses/0/1/notFound") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing notFound in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn ids(&self) -> impl Iterator { + self.0 + .pointer("/methodResponses/0/1/ids") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing ids in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn destroyed(&self) -> impl Iterator { + self.0 + .pointer("/methodResponses/0/1/destroyed") + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing destroyed in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + + pub fn not_destroyed(&self, id: &str) -> &Value { + self.0 + .pointer(&format!("/methodResponses/0/1/notDestroyed/{id}")) + .unwrap_or_else(|| panic!("Missing not destroyed item {id}: {self:?}")) + } + + pub fn state(&self) -> &str { + self.0 + .pointer("/methodResponses/0/1/state") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing state in response: {self:?}")) + } + + pub fn new_state(&self) -> &str { + self.0 + .pointer("/methodResponses/0/1/newState") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing new state in response: {self:?}")) + } + + pub fn changes(&self) -> impl Iterator> { + self.changes_by_type("created") + .map(ChangeType::Created) + .chain(self.changes_by_type("updated").map(ChangeType::Updated)) + .chain(self.changes_by_type("destroyed").map(ChangeType::Destroyed)) + } + + fn changes_by_type(&self, typ: &str) -> impl Iterator { + self.0 + .pointer(&format!("/methodResponses/0/1/{typ}")) + .and_then(|v| v.as_array()) + .unwrap_or_else(|| panic!("Missing {typ} changes in response: {self:?}")) + .iter() + .map(|v| v.as_str().unwrap()) + } + pub fn pointer(&self, pointer: &str) -> Option<&Value> { self.0.pointer(pointer) } @@ -561,6 +913,67 @@ impl JmapResponse { } } +pub trait JmapUtils { + fn id(&self) -> &str { + self.text_field("id") + } + fn typ(&self) -> &str { + self.text_field("type") + } + fn description(&self) -> &str { + self.text_field("description") + } + fn text_field(&self, field: &str) -> &str; + fn assert_is_equal(&self, other: Value); +} + +impl JmapUtils for Value { + fn text_field(&self, field: &str) -> &str { + self.pointer(&format!("/{field}")) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("Missing {field} in object: {self:?}")) + } + fn assert_is_equal(&self, expected: Value) { + if self != &expected { + panic!( + "Values are not equal:\nself: {}\nexpected: {}", + serde_json::to_string_pretty(self).unwrap(), + serde_json::to_string_pretty(&expected).unwrap() + ); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ChangeType<'x> { + Created(&'x str), + Updated(&'x str), + Destroyed(&'x str), +} + +impl<'x> ChangeType<'x> { + pub fn as_created(&self) -> &str { + match self { + ChangeType::Created(id) => id, + _ => panic!("Not a created change: {self:?}"), + } + } + + pub fn as_updated(&self) -> &str { + match self { + ChangeType::Updated(id) => id, + _ => panic!("Not an updated change: {self:?}"), + } + } + + pub fn as_destroyed(&self) -> &str { + match self { + ChangeType::Destroyed(id) => id, + _ => panic!("Not a destroyed change: {self:?}"), + } + } +} + impl Display for JmapResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { std::fmt::Display::fmt(&self.0, f) @@ -575,6 +988,20 @@ impl Debug for JmapResponse { } } +pub trait IntoJmapSet { + fn into_jmap_set(self) -> Value; +} + +impl> IntoJmapSet for T { + fn into_jmap_set(self) -> Value { + Value::Object( + self.into_iter() + .map(|id| (id.to_string(), Value::Bool(true))) + .collect::>(), + ) + } +} + pub fn find_values(string: &str, name: &str) -> Vec { let mut last_pos = 0; let mut values = Vec::new(); diff --git a/tests/src/jmap/server/purge.rs b/tests/src/jmap/server/purge.rs index 1f820b4e..566fb820 100644 --- a/tests/src/jmap/server/purge.rs +++ b/tests/src/jmap/server/purge.rs @@ -6,7 +6,7 @@ use crate::{ imap::{AssertResult, ImapConnection, Type}, - jmap::{JMAPTest, assert_is_empty}, + jmap::{JMAPTest}, }; use ahash::AHashSet; use common::Server; @@ -153,7 +153,7 @@ pub async fn test(params: &mut JMAPTest) { .delete_principal(QueryBy::Id(account.id().document_id())) .await .unwrap(); - assert_is_empty(server).await; + params.assert_is_empty().await; } async fn get_changes(server: &Server) -> (AHashSet<(u64, u8)>, bool) { diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index e47aef71..ac67acb3 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -276,7 +276,7 @@ impl WebDavTest { } pub async fn assert_is_empty(&self) { - assert_is_empty(self.server.clone()).await; + assert_is_empty(&self.server).await; self.clear_cache(); } } @@ -562,6 +562,15 @@ impl DavResponse { } } + 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