diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 86e76041..28ceb36d 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -50,6 +50,7 @@ pub struct GroupwareConfig { // Sharing settings pub max_shares_per_item: usize, + pub allow_directory_query: bool, } #[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] @@ -182,6 +183,9 @@ impl GroupwareConfig { None }, max_shares_per_item: config.property("sharing.max-shares-per-item").unwrap_or(10), + allow_directory_query: config + .property("sharing.allow-directory-query") + .unwrap_or(false), itip_http_rsvp_expiration: config .property_or_default::("calendar.scheduling.http-rsvp.expiration", "90d") .map(|d| d.as_secs()) diff --git a/crates/common/src/config/jmap/capabilities.rs b/crates/common/src/config/jmap/capabilities.rs index 0b8d7e91..e4dae1a3 100644 --- a/crates/common/src/config/jmap/capabilities.rs +++ b/crates/common/src/config/jmap/capabilities.rs @@ -83,7 +83,8 @@ impl JmapConfig { max_calendars_per_event: None, min_date_time: UTCDate::from_timestamp(DateTime::::MIN_UTC.timestamp()), max_date_time: UTCDate::from_timestamp(DateTime::::MAX_UTC.timestamp()), - max_expanded_query_duration: ICalendarDuration::from_seconds(86400 * 365), + max_expanded_query_duration: ICalendarDuration::from_seconds(86400 * 365) + .to_string(), max_participants_per_event: groupware_config.max_ical_attendees_per_instance.into(), may_create_calendar: true, }), @@ -152,7 +153,7 @@ impl JmapConfig { self.capabilities.account.insert( Capability::PrincipalsAvailability, Capabilities::PrincipalsAvailability(PrincipalAvailabilityCapabilities { - max_availability_duration: ICalendarDuration::from_seconds(86400 * 365), + max_availability_duration: ICalendarDuration::from_seconds(86400 * 365).to_string(), }), ); diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 617f5209..f95a08d9 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -191,7 +191,9 @@ impl PropFindRequestHandler for Server { StatusCode::NOT_FOUND, )); } else if access_token.has_account_access(account_id) - || access_token.has_permission(Permission::DavPrincipalList) + || (self.core.groupware.allow_directory_query + && access_token.has_permission(Permission::DavPrincipalList)) + || access_token.has_permission(Permission::IndividualList) { self.prepare_principal_propfind_response( access_token, @@ -244,7 +246,10 @@ impl PropFindRequestHandler for Server { RoaringBitmap::from_iter( access_token.all_ids_by_collection(resource.collection), ) - } else if access_token.has_permission(Permission::DavPrincipalList) { + } else if (self.core.groupware.allow_directory_query + && access_token.has_permission(Permission::DavPrincipalList)) + || access_token.has_permission(Permission::IndividualList) + { // Return all principals let principals = self .store() diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs index dd6bda6a..0dfa092d 100644 --- a/crates/dav/src/request.rs +++ b/crates/dav/src/request.rs @@ -5,7 +5,7 @@ */ use crate::{ - DavError, DavMethod, DavResourceName, + DavError, DavErrorCondition, DavMethod, DavResourceName, calendar::{ copy_move::CalendarCopyMoveRequestHandler, delete::CalendarDeleteRequestHandler, freebusy::CalendarFreebusyRequestHandler, get::CalendarGetRequestHandler, @@ -43,7 +43,7 @@ use dav_proto::{ property::WebDavProperty, request::{Acl, LockInfo, MkCol, PropFind, PropertyUpdate, Report}, response::{ - BaseCondition, ErrorResponse, PrincipalSearchProperty, PrincipalSearchPropertySet, + BaseCondition, ErrorResponse, List, PrincipalSearchProperty, PrincipalSearchPropertySet, }, }, }; @@ -184,6 +184,17 @@ impl DavRequestDispatcher for Server { } Report::AclPrincipalPropSet(report) => { // Validate permissions + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::IndividualList) + { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NeedPrivileges(List(Default::default())), + ) + .with_details("The administrator has disabled directory queries."), + )); + } access_token.assert_has_permission(Permission::DavPrincipalAcl)?; self.handle_acl_prop_set(&access_token, headers, report) @@ -191,6 +202,17 @@ impl DavRequestDispatcher for Server { } Report::PrincipalMatch(report) => { // Validate permissions + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::IndividualList) + { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NeedPrivileges(List(Default::default())), + ) + .with_details("The administrator has disabled directory queries."), + )); + } access_token.assert_has_permission(Permission::DavPrincipalMatch)?; self.handle_principal_match(&access_token, headers, report) @@ -199,6 +221,18 @@ impl DavRequestDispatcher for Server { Report::PrincipalPropertySearch(report) => { if resource == DavResourceName::Principal { // Validate permissions + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::IndividualList) + { + return Err(DavError::Condition( + DavErrorCondition::new( + StatusCode::FORBIDDEN, + BaseCondition::NeedPrivileges(List(Default::default())), + ) + .with_details("The administrator has disabled directory queries."), + )); + } + access_token.assert_has_permission(Permission::DavPrincipalSearch)?; self.handle_principal_property_search(&access_token, report) diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index 655666da..434b8ff7 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -1384,6 +1384,8 @@ impl Permission { | Permission::DavSyncCollection | Permission::DavExpandProperty | Permission::DavPrincipalAcl + | Permission::DavPrincipalList + | Permission::DavPrincipalSearch | Permission::DavPrincipalMatch | Permission::DavPrincipalSearchPropSet | Permission::DavFilePropFind @@ -1441,6 +1443,9 @@ impl Permission { | Permission::JmapFileNodeQueryChanges | Permission::JmapPrincipalGetAvailability | Permission::JmapPrincipalChanges + | Permission::JmapPrincipalQuery + | Permission::JmapPrincipalGet + | Permission::JmapPrincipalQueryChanges | Permission::JmapShareNotificationGet | Permission::JmapShareNotificationSet | Permission::JmapShareNotificationChanges @@ -1523,9 +1528,6 @@ impl Permission { | Permission::Undelete | Permission::DkimSignatureCreate | Permission::DkimSignatureGet - | Permission::JmapPrincipalGet - | Permission::JmapPrincipalQueryChanges - | Permission::JmapPrincipalQuery | Permission::ApiKeyList | Permission::ApiKeyGet | Permission::ApiKeyCreate diff --git a/crates/jmap-proto/src/object/calendar.rs b/crates/jmap-proto/src/object/calendar.rs index 4570b824..e17c6886 100644 --- a/crates/jmap-proto/src/object/calendar.rs +++ b/crates/jmap-proto/src/object/calendar.rs @@ -414,7 +414,7 @@ impl JmapRight for CalendarRight { CalendarRight::MayUpdatePrivate => &[Acl::ModifyPrivateProperties], CalendarRight::MayRSVP => &[Acl::ModifyRSVP], CalendarRight::MayShare => &[Acl::Share], - CalendarRight::MayDelete => &[Acl::Delete], + CalendarRight::MayDelete => &[Acl::Delete, Acl::RemoveItems], } } diff --git a/crates/jmap-proto/src/object/calendar_event_notification.rs b/crates/jmap-proto/src/object/calendar_event_notification.rs index 0620ae25..8e543245 100644 --- a/crates/jmap-proto/src/object/calendar_event_notification.rs +++ b/crates/jmap-proto/src/object/calendar_event_notification.rs @@ -12,7 +12,7 @@ use crate::{ use calcard::jscalendar::JSCalendar; use jmap_tools::{Element, Key, Property}; use serde::Serialize; -use std::{borrow::Cow, str::FromStr}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; use types::{blob::BlobId, id::Id}; #[derive(Debug, Clone, Default)] @@ -398,3 +398,9 @@ impl serde::Serialize for CalendarEventNotificationType { serializer.serialize_str(self.as_str()) } } + +impl Display for CalendarEventNotificationProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.to_cow()) + } +} diff --git a/crates/jmap-proto/src/object/participant_identity.rs b/crates/jmap-proto/src/object/participant_identity.rs index ebe862f9..d66f5596 100644 --- a/crates/jmap-proto/src/object/participant_identity.rs +++ b/crates/jmap-proto/src/object/participant_identity.rs @@ -9,7 +9,7 @@ use crate::{ request::{deserialize::DeserializeArguments, reference::MaybeIdReference}, }; use jmap_tools::{Element, Key, Property}; -use std::{borrow::Cow, str::FromStr}; +use std::{borrow::Cow, fmt::Display, str::FromStr}; use types::id::Id; #[derive(Debug, Clone, Default)] @@ -76,6 +76,15 @@ impl ParticipantIdentityProperty { b"isDefault" => ParticipantIdentityProperty::IsDefault ) } + + fn as_str(&self) -> &'static str { + match self { + ParticipantIdentityProperty::Id => "id", + ParticipantIdentityProperty::Name => "name", + ParticipantIdentityProperty::CalendarAddress => "calendarAddress", + ParticipantIdentityProperty::IsDefault => "isDefault", + } + } } #[derive(Debug, Clone, Default)] @@ -188,3 +197,9 @@ impl JmapObjectId for ParticipantIdentityProperty { false } } + +impl Display for ParticipantIdentityProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} diff --git a/crates/jmap-proto/src/object/principal.rs b/crates/jmap-proto/src/object/principal.rs index bb13e9eb..e0af7a7b 100644 --- a/crates/jmap-proto/src/object/principal.rs +++ b/crates/jmap-proto/src/object/principal.rs @@ -91,7 +91,7 @@ impl Element for PrincipalValue { } impl PrincipalProperty { - fn parse(value: &str) -> Option { + pub fn parse(value: &str) -> Option { hashify::tiny_map!(value.as_bytes(), b"id" => PrincipalProperty::Id, b"type" => PrincipalProperty::Type, @@ -103,6 +103,21 @@ impl PrincipalProperty { b"accounts" => PrincipalProperty::Accounts, ) } + + pub fn as_str(&self) -> &'static str { + match self { + PrincipalProperty::Id => "id", + PrincipalProperty::Type => "type", + PrincipalProperty::Name => "name", + PrincipalProperty::Description => "description", + PrincipalProperty::Email => "email", + PrincipalProperty::Timezone => "timeZone", + PrincipalProperty::Capabilities => "capabilities", + PrincipalProperty::Accounts => "accounts", + PrincipalProperty::Capability(cap) => cap.as_str(), + PrincipalProperty::IdValue(_) => "", + } + } } impl PrincipalType { @@ -331,3 +346,9 @@ impl JmapObjectId for PrincipalProperty { false } } + +impl Display for PrincipalProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index a47f0278..bd9ffeff 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -12,7 +12,6 @@ use crate::{ types::date::UTCDate, }; use ahash::AHashMap; -use calcard::icalendar::ICalendarDuration; use serde::{Deserialize, Deserializer}; use types::{id::Id, type_state::DataType}; use utils::map::vec_map::VecMap; @@ -210,7 +209,7 @@ pub struct CalendarCapabilities { #[serde(rename(serialize = "maxDateTime"))] pub max_date_time: UTCDate, #[serde(rename(serialize = "maxExpandedQueryDuration"))] - pub max_expanded_query_duration: ICalendarDuration, + pub max_expanded_query_duration: String, #[serde(rename(serialize = "maxParticipantsPerEvent"))] pub max_participants_per_event: Option, #[serde(rename(serialize = "mayCreateCalendar"))] @@ -228,7 +227,7 @@ pub struct ContactsCapabilities { #[derive(Debug, Clone, serde::Serialize)] pub struct PrincipalAvailabilityCapabilities { #[serde(rename(serialize = "maxAvailabilityDuration"))] - pub max_availability_duration: ICalendarDuration, + pub max_availability_duration: String, } #[derive(Debug, Clone, serde::Serialize)] diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index dd85f955..fd4177dd 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -8,7 +8,7 @@ use crate::{calendar_event::CalendarSyntheticId, changes::state::JmapCacheState} use calcard::{ common::{PartialDateTime, timezone::Tz}, icalendar::{ - ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole, ICalendarProperty, ICalendarValue, }, @@ -16,6 +16,7 @@ use calcard::{ JSCalendarDateTime, JSCalendarProperty, JSCalendarValue, import::ConversionOptions, }, }; +use chrono::DateTime; use common::{Server, auth::AccessToken}; use groupware::{ cache::GroupwareCache, @@ -30,7 +31,7 @@ use jmap_proto::{ request::{IntoValid, reference::MaybeResultReference}, }; use jmap_tools::{Key, Map, Value}; -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use store::{ ahash::{AHashMap, AHashSet}, roaring::RoaringBitmap, @@ -281,6 +282,7 @@ impl CalendarEventGet for Server { is_recurrent || component.is_recurrence_override(); let mut has_duration = false; let component_ids = &component.component_ids; + let mut tz = None; let mut component = ICalendarComponent { component_type: component.component_type.clone(), component_ids: Vec::new(), @@ -294,7 +296,16 @@ impl CalendarEventGet for Server { | ICalendarProperty::Exrule | ICalendarProperty::Rdate | ICalendarProperty::Rrule - | ICalendarProperty::RecurrenceId => false, + | ICalendarProperty::RecurrenceId => { + if let Some(new_tz) = entry + .tz_id() + .and_then(|id| Tz::from_str(id).ok()) + .filter(|tz| *tz != Tz::UTC) + { + tz = Some(new_tz); + } + false + } ICalendarProperty::Due | ICalendarProperty::Completed | ICalendarProperty::Created => is_recurrent, @@ -307,28 +318,46 @@ impl CalendarEventGet for Server { .cloned() .collect::>(), }; + + let tz = tz.unwrap_or(default_tz); + let tz_name = tz.name().unwrap_or_default().to_string(); + + let start_timestamp = DateTime::from_timestamp(expansion.start, 0) + .map(|dt| dt.with_timezone(&tz)) + .map(|dt| dt.naive_local()) + .map(|dt| dt.and_utc().timestamp()) + .unwrap_or(expansion.start); + + let end_timestamp = DateTime::from_timestamp(expansion.end, 0) + .map(|dt| dt.with_timezone(&tz)) + .map(|dt| dt.naive_local()) + .map(|dt| dt.and_utc().timestamp()) + .unwrap_or(expansion.end); + component.entries.push(ICalendarEntry { name: ICalendarProperty::Dtstart, - params: vec![], + params: vec![ICalendarParameter::tzid(tz_name.clone())], values: vec![ICalendarValue::PartialDateTime(Box::new( - PartialDateTime::from_utc_timestamp(expansion.start), + PartialDateTime::from_naive_timestamp(start_timestamp), ))], }); + if is_recurrent_or_override { component.entries.push(ICalendarEntry { name: ICalendarProperty::RecurrenceId, - params: vec![], + params: vec![ICalendarParameter::tzid(tz_name.clone())], values: vec![ICalendarValue::PartialDateTime(Box::new( - PartialDateTime::from_utc_timestamp(expansion.start), + PartialDateTime::from_naive_timestamp(start_timestamp), ))], }); } + if !has_duration { component.entries.push(ICalendarEntry { name: ICalendarProperty::Dtend, - params: vec![], + params: vec![ICalendarParameter::tzid(tz_name)], values: vec![ICalendarValue::PartialDateTime(Box::new( - PartialDateTime::from_utc_timestamp(expansion.end), + PartialDateTime::from_naive_timestamp(end_timestamp), ))], }); } diff --git a/crates/jmap/src/calendar_event/mod.rs b/crates/jmap/src/calendar_event/mod.rs index 033ddf48..1094a4d0 100644 --- a/crates/jmap/src/calendar_event/mod.rs +++ b/crates/jmap/src/calendar_event/mod.rs @@ -28,6 +28,10 @@ TODO: Not yet implemented: - CalendarEvent/set - synthetic id update and removal +- Principal/getAvailability + - If there are overlapping BusyPeriod time ranges with different "busyStatus" properties + the server MUST choose the value in the following order: confirmed > unavailable > tentative. + - Return event properties */ diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index a5631e7e..843d9935 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -484,6 +484,8 @@ impl CalendarEventSet for Server { ) .caused_by(trc::location!())?; + nudge_queue |= send_scheduling_messages; + response.destroyed.push(id); } diff --git a/crates/jmap/src/participant_identity/get.rs b/crates/jmap/src/participant_identity/get.rs index 70919075..04572198 100644 --- a/crates/jmap/src/participant_identity/get.rs +++ b/crates/jmap/src/participant_identity/get.rs @@ -17,7 +17,7 @@ use store::{ write::{AlignedBytes, Archive, Archiver, BatchBuilder}, }; use trc::AddContext; -use types::{collection::Collection, field::PrincipalField}; +use types::{collection::Collection, field::PrincipalField, id::Id}; pub trait ParticipantIdentityGet: Sync + Send { fn participant_identity_get( @@ -65,9 +65,11 @@ impl ParticipantIdentityGet for Server { let ids = if let Some(ids) = ids { ids } else { - (0..identities.identities.len() as u32) + identities + .identities + .iter() .take(self.core.jmap.get_max_objects) - .map(Into::into) + .map(|i| Id::from(i.id.to_native())) .collect::>() }; @@ -78,15 +80,6 @@ impl ParticipantIdentityGet for Server { response.not_found.push(id); continue; }; - let _identity = if let Some(identity) = self - .get_archive(account_id, Collection::Identity, document_id) - .await? - { - identity - } else { - response.not_found.push(id); - continue; - }; let mut result = Map::with_capacity(properties.len()); for property in &properties { @@ -153,7 +146,7 @@ impl ParticipantIdentityGet for Server { } // Build identities - let identities = Archiver::new(ParticipantIdentities { + let identities = ParticipantIdentities { identities: principal .emails .iter() @@ -166,16 +159,19 @@ impl ParticipantIdentityGet for Server { .collect(), default: 0, default_name: principal.description.unwrap_or(principal.name), - }) - .serialize() - .caused_by(trc::location!())?; + }; let mut batch = BatchBuilder::new(); batch .with_account_id(account_id) .with_collection(Collection::Principal) .update_document(0) - .set(PrincipalField::ParticipantIdentities, identities); + .set( + PrincipalField::ParticipantIdentities, + Archiver::new(identities) + .serialize() + .caused_by(trc::location!())?, + ); self.commit_batch(batch).await.caused_by(trc::location!())?; diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs index 12b16e33..9b3865e6 100644 --- a/crates/jmap/src/principal/availability.rs +++ b/crates/jmap/src/principal/availability.rs @@ -15,6 +15,7 @@ use calcard::{ jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue}, }; use common::{Server, TinyCalendarPreferences, auth::AccessToken}; +use directory::Permission; use groupware::{ cache::GroupwareCache, calendar::{CALENDAR_SUBSCRIBED, CalendarEvent}, @@ -53,6 +54,14 @@ impl PrincipalGetAvailability for Server { request: GetAvailabilityRequest, access_token: &AccessToken, ) -> trc::Result { + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::IndividualList) + { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("The administrator has disabled directory queries.".to_string())); + } + // Process parameters if !request.id.is_valid() { return Err(trc::JmapEvent::InvalidArguments @@ -94,13 +103,16 @@ impl PrincipalGetAvailability for Server { let is_account_owner = principal_id == account_id; let shared_ids = if !access_token.is_member(account_id) { // Condition: The user has the "mayReadFreeBusy" permission for the calendar. - resources - .shared_containers( - access_token, - [Acl::ReadItems, Acl::SchedulingReadFreeBusy], - true, - ) - .into() + let shared_ids = resources.shared_items( + access_token, + [Acl::ReadItems, Acl::SchedulingReadFreeBusy], + true, + ); + if shared_ids.is_empty() { + continue; + } + + shared_ids.into() } else { None }; @@ -158,6 +170,7 @@ impl PrincipalGetAvailability for Server { IncludeInAvailability::None } }); + if !is_subscribed || include_in_availability == IncludeInAvailability::None { continue 'next_event; } diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs index e33cf172..50bb09b1 100644 --- a/crates/jmap/src/principal/get.rs +++ b/crates/jmap/src/principal/get.rs @@ -5,7 +5,7 @@ */ use common::{Server, auth::AccessToken}; -use directory::{QueryParams, Type, backend::internal::manage::ManageDirectory}; +use directory::{Permission, QueryParams, Type, backend::internal::manage::ManageDirectory}; use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::principal::{Principal, PrincipalProperty, PrincipalType, PrincipalValue}, @@ -15,7 +15,7 @@ use jmap_proto::{ use jmap_tools::{Key, Map, Value}; use std::future::Future; use store::roaring::RoaringBitmap; -use types::collection::Collection; +use trc::AddContext; pub trait PrincipalGet: Sync + Send { fn principal_get( @@ -31,6 +31,14 @@ impl PrincipalGet for Server { mut request: GetRequest, access_token: &AccessToken, ) -> trc::Result> { + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::IndividualList) + { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("The administrator has disabled directory queries.".to_string())); + } + let ids = request.unwrap_ids(self.core.jmap.get_max_objects)?; let properties = request.unwrap_properties(&[ PrincipalProperty::Id, @@ -39,19 +47,30 @@ impl PrincipalGet for Server { PrincipalProperty::Description, PrincipalProperty::Email, ]); - let principal_ids = if access_token.tenant.is_some() { - self.store() - .list_principals(None, access_token.tenant.map(|t| t.id), &[], false, 0, 0) - .await? - .items - .into_iter() - .map(|p| p.id()) - .collect::() - } else { - self.get_document_ids(u32::MAX, Collection::Principal) - .await? - .unwrap_or_default() - }; + + // Return all principals + let principal_ids = self + .store() + .list_principals( + None, + access_token.tenant_id(), + &[ + Type::Individual, + Type::Group, + Type::Resource, + Type::Location, + ], + false, + 0, + 0, + ) + .await + .caused_by(trc::location!())? + .items + .into_iter() + .map(|p| p.id()) + .collect::(); + let ids = if let Some(ids) = ids { ids } else { diff --git a/crates/jmap/src/principal/query.rs b/crates/jmap/src/principal/query.rs index 202e28ba..ecdb7a16 100644 --- a/crates/jmap/src/principal/query.rs +++ b/crates/jmap/src/principal/query.rs @@ -6,7 +6,7 @@ use crate::JmapMethods; use common::{Server, auth::AccessToken}; -use directory::{QueryParams, Type, backend::internal::manage::ManageDirectory}; +use directory::{Permission, QueryParams, Type, backend::internal::manage::ManageDirectory}; use http_proto::HttpSessionData; use jmap_proto::{ method::query::{Filter, QueryRequest, QueryResponse}, @@ -15,6 +15,7 @@ use jmap_proto::{ }; use std::future::Future; use store::{query::ResultSet, roaring::RoaringBitmap}; +use trc::AddContext; use types::collection::Collection; pub trait PrincipalQuery: Sync + Send { @@ -33,25 +34,41 @@ impl PrincipalQuery for Server { access_token: &AccessToken, session: &HttpSessionData, ) -> trc::Result { + if !self.core.groupware.allow_directory_query + && !access_token.has_permission(Permission::IndividualList) + { + return Err(trc::JmapEvent::Forbidden + .into_err() + .details("The administrator has disabled directory queries.".to_string())); + } + let mut result_set = ResultSet { account_id: request.account_id.document_id(), collection: Collection::Principal, results: RoaringBitmap::new(), }; let mut is_set = true; - let all_ids = if access_token.tenant.is_some() { - self.store() - .list_principals(None, access_token.tenant.map(|t| t.id), &[], false, 0, 0) - .await? - .items - .into_iter() - .map(|p| p.id()) - .collect::() - } else { - self.get_document_ids(u32::MAX, Collection::Principal) - .await? - .unwrap_or_default() - }; + let principal_ids = self + .store() + .list_principals( + None, + access_token.tenant_id(), + &[ + Type::Individual, + Type::Group, + Type::Resource, + Type::Location, + ], + false, + 0, + 0, + ) + .await + .caused_by(trc::location!())? + .items + .into_iter() + .map(|p| p.id()) + .collect::(); for cond in std::mem::take(&mut request.filter) { match cond { @@ -95,7 +112,11 @@ impl PrincipalQuery for Server { .into_iter() .filter_map(|id| { let id = id.document_id(); - if all_ids.contains(id) { Some(id) } else { None } + if principal_ids.contains(id) { + Some(id) + } else { + None + } }) .collect::(); if is_set { @@ -176,9 +197,9 @@ impl PrincipalQuery for Server { } if is_set { - result_set.results = all_ids; + result_set.results = principal_ids; } else { - result_set.results &= all_ids; + result_set.results &= principal_ids; } let (response, paginate) = self diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs index f6336379..d4a78234 100644 --- a/tests/src/jmap/calendar/acl.rs +++ b/tests/src/jmap/calendar/acl.rs @@ -5,10 +5,707 @@ */ use crate::jmap::{JMAPTest, JmapUtils}; -use jmap_proto::request::method::MethodObject; +use calcard::jscalendar::JSCalendarProperty; +use jmap_proto::{ + object::{calendar::CalendarProperty, share_notification::ShareNotificationProperty}, + request::method::MethodObject, +}; use serde_json::json; +use types::id::Id; pub async fn test(params: &mut JMAPTest) { - println!("Running tests..."); - let account = params.account("jdoe@example.com"); + println!("Running Calendar 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 calendars + let response = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let john_calendar_id = response.created(0).id().to_string(); + let john_event_id = john + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "@type": "Event", + "uid": "a8df6573-0474-496d-8496-033ad45d7fea", + "updated": "2020-01-02T18:23:04Z", + "title": "John's Simple Event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &john_calendar_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = jane + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test #1", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let jane_calendar_id = response.created(0).id().to_string(); + let jane_event_id = jane + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "uid": "a8df6575-0474-496d-8496-033ad45d7fea", + "updated": "2020-01-02T18:23:04Z", + "title": "Jane's Simple Event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &jane_calendar_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + + // Verify myRights + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::MyRights, + CalendarProperty::ShareWith, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "myRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": true, + "mayWriteOwn": true, + "mayReadFreeBusy": true, + "mayUpdatePrivate": true, + "mayRSVP": 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::Calendar, + Vec::<&str>::new(), + [john_calendar_id.as_str()], + ) + .await + .method_response() + .typ(), + "forbidden" + ); + + // Share calendar with Jane + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + "shareWith": { + &jane_id : { + "mayReadItems": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::ShareWith, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "shareWith": { + &jane_id : { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + } + } + })); + + // Verify Jane can access the event + jane.jmap_get_account( + john, + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::MyRights, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "myRights": { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + } + })); + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [john_event_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_event_id, + "title": "John's Simple Event", + })); + + // 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": "Calendar", + "objectAccountId": &john_id, + "objectId": &john_calendar_id, + "oldRights": { + "mayReadItems": false, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "newRights": { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "name": null + })); + + // Updating and deleting should fail + assert_eq!( + jane.jmap_update_account( + john, + MethodObject::Calendar, + [(&john_calendar_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_calendar_id) + .description(), + "You are not allowed to modify this calendar." + ); + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::Calendar, + [&john_calendar_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_calendar_id) + .description(), + "You are not allowed to delete this calendar." + ); + assert!( + jane.jmap_update_account( + john, + MethodObject::CalendarEvent, + [(&john_event_id, json!({}))], + Vec::<(&str, &str)>::new(), + ) + .await + .not_updated(&john_event_id) + .description() + .contains("You are not allowed to modify calendar"), + ); + assert!( + jane.jmap_destroy_account( + john, + MethodObject::CalendarEvent, + [&john_event_id], + Vec::<(&str, &str)>::new(), + ) + .await + .not_destroyed(&john_event_id) + .description() + .contains("You are not allowed to remove events from calendar"), + ); + + // Grant Jane write access + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + format!("shareWith/{jane_id}/mayWriteAll"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + jane.jmap_get_account( + john, + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::MyRights, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "myRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": 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": "Calendar", + "objectAccountId": &john_id, + "objectId": &john_calendar_id, + "oldRights": { + "mayReadItems": true, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "newRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "name": null + })); + + // Creating a root folder should fail + assert_eq!( + jane.jmap_create_account( + john, + MethodObject::Calendar, + [json!({ + "name": "A new shared calendar", + })], + Vec::<(&str, &str)>::new() + ) + .await + .not_created(0) + .description(), + "Cannot create calendars in a shared account." + ); + + // Copy Jane's event into John's calendar + let john_copied_event_id = jane + .jmap_copy( + jane, + john, + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "calendarIds": { + &john_calendar_id: true + } + }), + )], + false, + ) + .await + .copied(&jane_event_id) + .id() + .to_string(); + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::CalendarIds, + JSCalendarProperty::Title, + ], + [john_copied_event_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_copied_event_id, + "title": "Jane's Simple Event", + "calendarIds": { + &john_calendar_id: true + } + })); + + // Destroy the copied event + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::CalendarEvent, + [john_copied_event_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .collect::>(), + [&john_copied_event_id] + ); + + // Update John's event + jane.jmap_update_account( + john, + MethodObject::CalendarEvent, + [( + &john_event_id, + json!({ + "title": "John's Updated Event", + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_event_id); + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [john_event_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_event_id, + "title": "John's Updated Event", + })); + + // Update John's calendar name + jane.jmap_update_account( + john, + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + "name": "Jane's version of John's Calendar", + "description": "This is John's calendar, but Jane can edit it now" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + jane.jmap_get_account( + john, + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Jane's version of John's Calendar", + "description": "This is John's calendar, but Jane can edit it now" + })); + + // John should still see the old name + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::Description, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "description": null + })); + + // Revoke Jane's access + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + format!("shareWith/{jane_id}"): () + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + john.jmap_get( + MethodObject::Calendar, + [ + CalendarProperty::Id, + CalendarProperty::Name, + CalendarProperty::ShareWith, + ], + [john_calendar_id.as_str()], + ) + .await + .list()[0] + .assert_is_equal(json!({ + "id": john_calendar_id, + "name": "Test #1", + "shareWith": {} + })); + + // Verify Jane can no longer access the calendar or its events + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::Calendar, + Vec::<&str>::new(), + [john_calendar_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": "Calendar", + "objectAccountId": &john_id, + "objectId": &john_calendar_id, + "oldRights": { + "mayReadItems": true, + "mayWriteAll": true, + "mayDelete": true, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "newRights": { + "mayReadItems": false, + "mayWriteAll": false, + "mayDelete": false, + "mayShare": false, + "mayWriteOwn": false, + "mayReadFreeBusy": false, + "mayUpdatePrivate": false, + "mayRSVP": false + }, + "name": null + })); + + // Grant Jane delete access once again + john.jmap_update( + MethodObject::Calendar, + [( + &john_calendar_id, + json!({ + format!("shareWith/{jane_id}/mayReadItems"): true, + format!("shareWith/{jane_id}/mayDelete"): true, + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&john_calendar_id); + + // Verify Jane can delete the calendar + assert_eq!( + jane.jmap_destroy_account( + john, + MethodObject::Calendar, + [john_calendar_id.as_str()], + [("onDestroyRemoveEvents", true)], + ) + .await + .destroyed() + .collect::>(), + [john_calendar_id.as_str()] + ); + + // Destroy all mailboxes + john.destroy_all_calendars().await; + jane.destroy_all_calendars().await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/calendar/calendars.rs b/tests/src/jmap/calendar/calendars.rs index fd8e1dc4..134c878c 100644 --- a/tests/src/jmap/calendar/calendars.rs +++ b/tests/src/jmap/calendar/calendars.rs @@ -101,6 +101,7 @@ pub async fn test(params: &mut JMAPTest) { } } })], + Vec::<(&str, &str)>::new(), ) .await .created(0) @@ -333,6 +334,7 @@ pub async fn test(params: &mut JMAPTest) { "timeZone": "America/New_York", "duration": "PT1H" })], + Vec::<(&str, &str)>::new(), ) .await .created(0) diff --git a/tests/src/jmap/calendar/event.rs b/tests/src/jmap/calendar/event.rs index 807c4bad..7e312f58 100644 --- a/tests/src/jmap/calendar/event.rs +++ b/tests/src/jmap/calendar/event.rs @@ -17,7 +17,7 @@ use serde_json::{Value, json}; use types::{collection::SyncCollection, id::Id}; pub async fn test(params: &mut JMAPTest) { - println!("Running tests..."); + println!("Running Calendar Event tests..."); let account = params.account("jdoe@example.com"); // Create test calendars @@ -42,6 +42,7 @@ pub async fn test(params: &mut JMAPTest) { }, }), ], + Vec::<(&str, &str)>::new(), ) .await; let calendar1_id = response.created(0).id().to_string(); @@ -86,6 +87,7 @@ pub async fn test(params: &mut JMAPTest) { .with_property(JSCalendarProperty::::UseDefaultAlerts, true), event_3.clone(), ], + Vec::<(&str, &str)>::new(), ) .await; let event_1_id = response.created(0).id().to_string(); @@ -240,6 +242,7 @@ pub async fn test(params: &mut JMAPTest) { "timeZone": "US/Eastern", "calendarIds": {}, }),], + Vec::<(&str, &str)>::new() ) .await .not_created(0) @@ -262,6 +265,7 @@ pub async fn test(params: &mut JMAPTest) { &calendar1_id: true }, })], + Vec::<(&str, &str)>::new() ) .await .not_created(0) @@ -480,6 +484,7 @@ pub async fn test(params: &mut JMAPTest) { JSCalendarProperty::BaseEventId, JSCalendarProperty::Start, JSCalendarProperty::Duration, + JSCalendarProperty::TimeZone, JSCalendarProperty::Title, JSCalendarProperty::RecurrenceId, ], @@ -491,54 +496,61 @@ pub async fn test(params: &mut JMAPTest) { { "duration": "PT1H", "title": "Event one", - "start": "2006-01-02T15:00:00", + "start": "2006-01-02T10:00:00", + "timeZone": "US/Eastern", "id": &ids[0], "baseEventId": &event_1_id }, { - "recurrenceId": "2006-01-02T17:00:00", + "recurrenceId": "2006-01-02T12:00:00", "title": "Event two", "duration": "PT1H", - "start": "2006-01-02T17:00:00", + "start": "2006-01-02T12:00:00", + "timeZone": "US/Eastern", "id": &ids[1], "baseEventId": &event_2_id }, { "duration": "PT1H", - "start": "2006-01-03T17:00:00", + "start": "2006-01-03T12:00:00", + "timeZone": "US/Eastern", "title": "Event two", - "recurrenceId": "2006-01-03T17:00:00", + "recurrenceId": "2006-01-03T12:00:00", "id": &ids[2], "baseEventId": &event_2_id }, { - "start": "2006-01-04T14:00:00", + "start": "2006-01-04T09:00:00", + "timeZone": "US/Eastern", "duration": "PT2H", "title": "Event three", "id": &ids[3], "baseEventId": &event_3_id }, { - "recurrenceId": "2006-01-04T19:00:00", + "recurrenceId": "2006-01-04T14:00:00", "title": "Event two overridden", - "start": "2006-01-04T19:00:00", + "start": "2006-01-04T14:00:00", + "timeZone": "US/Eastern", "duration": "PT1H", "id": &ids[4], "baseEventId": &event_2_id }, { - "recurrenceId": "2006-01-05T17:00:00", + "recurrenceId": "2006-01-05T12:00:00", "duration": "PT1H", - "start": "2006-01-05T17:00:00", + "timeZone": "US/Eastern", + "start": "2006-01-05T12:00:00", "title": "Event two", "id": &ids[5], "baseEventId": &event_2_id }, { - "recurrenceId": "2006-01-06T19:00:00", + "recurrenceId": "2006-01-06T14:00:00", "duration": "PT1H", "title": "Event two overridden twice", - "start": "2006-01-06T19:00:00", + "timeZone": "US/Eastern", + "start": "2006-01-06T14:00:00", "id": &ids[6], "baseEventId": &event_2_id } diff --git a/tests/src/jmap/calendar/identity.rs b/tests/src/jmap/calendar/identity.rs index f63876cd..47bd8b37 100644 --- a/tests/src/jmap/calendar/identity.rs +++ b/tests/src/jmap/calendar/identity.rs @@ -4,13 +4,148 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ - use crate::jmap::{JMAPTest, JmapUtils}; -use jmap_proto::request::method::MethodObject; +use crate::jmap::{JMAPTest, JmapUtils}; +use jmap_proto::{ + object::participant_identity::ParticipantIdentityProperty, request::method::MethodObject, +}; use serde_json::json; +use store::write::BatchBuilder; +use types::{collection::Collection, field::PrincipalField}; pub async fn test(params: &mut JMAPTest) { - println!("Running tests..."); + println!("Running Participant Identity tests..."); let account = params.account("jdoe@example.com"); + // Obtain all identities + let response = account + .jmap_get( + MethodObject::ParticipantIdentity, + [ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([ + { + "id": "a", + "name": "John Doe", + "calendarAddress": "mailto:jdoe@example.com", + "isDefault": true + }, + { + "id": "b", + "name": "John Doe", + "calendarAddress": "mailto:john.doe@example.com", + "isDefault": false + } + ])); + // Destroy identity b + let response = account + .jmap_destroy( + MethodObject::ParticipantIdentity, + ["b"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.destroyed().next(), Some("b")); + let response = account + .jmap_get( + MethodObject::ParticipantIdentity, + [ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([ + { + "id": "a", + "name": "John Doe", + "calendarAddress": "mailto:jdoe@example.com", + "isDefault": true + } + ])); + + // Creating a new identity with an unauthorized calendar address should fail + let response = account + .jmap_create( + MethodObject::ParticipantIdentity, + [ + json!({ + "name": "Work", + "calendarAddress": "mailto:work@example.com" + }), + json!({ + "name": "Work", + "calendarAddress": "work@example.com" + }), + ], + [("onSuccessSetIsDefault", "#i0")], + ) + .await; + assert_eq!( + response.not_created(0).description(), + "Calendar address not configured for this account." + ); + assert_eq!( + response.not_created(1).description(), + "Calendar address not configured for this account." + ); + + // Create a new identity and set it as default + let response = account + .jmap_create( + MethodObject::ParticipantIdentity, + [json!({ + "name": "Johnny B Goode", + "calendarAddress": "mailto:john.doe@example.com" + })], + [("onSuccessSetIsDefault", "#i0")], + ) + .await; + response.created(0); + let response = account + .jmap_get( + MethodObject::ParticipantIdentity, + [ + ParticipantIdentityProperty::Id, + ParticipantIdentityProperty::Name, + ParticipantIdentityProperty::CalendarAddress, + ParticipantIdentityProperty::IsDefault, + ], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([ + { + "id": "a", + "name": "John Doe", + "calendarAddress": "mailto:jdoe@example.com", + "isDefault": false + }, + { + "id": "b", + "name": "Johnny B Goode", + "calendarAddress": "mailto:john.doe@example.com", + "isDefault": true + } + ])); + + // Cleanup + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account.id().document_id()) + .with_collection(Collection::Principal) + .update_document(0) + .clear(PrincipalField::ParticipantIdentities); + params.server.commit_batch(batch).await.unwrap(); + params.assert_is_empty().await; } diff --git a/tests/src/jmap/calendar/notification.rs b/tests/src/jmap/calendar/notification.rs index f6336379..3866474c 100644 --- a/tests/src/jmap/calendar/notification.rs +++ b/tests/src/jmap/calendar/notification.rs @@ -4,11 +4,447 @@ * 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; +use crate::jmap::{IntoJmapSet, JMAPTest, JmapUtils}; +use calcard::jscalendar::JSCalendarProperty; +use jmap_proto::{ + object::calendar_event_notification::CalendarEventNotificationProperty, + request::method::MethodObject, +}; +use mail_parser::DateTime; +use serde_json::{Value, json}; +use store::write::now; +use types::id::Id; pub async fn test(params: &mut JMAPTest) { - println!("Running tests..."); - let account = params.account("jdoe@example.com"); + println!("Running Calendar Event Notification tests..."); + let john = params.account("jdoe@example.com"); + let jane = params.account("jane.smith@example.com"); + let bill = params.account("bill@example.com"); + + let john_id = john.id_string().to_string(); + let jane_id = jane.id_string().to_string(); + let bill_id = bill.id_string().to_string(); + + let mut john_change_id = String::new(); + let mut jane_change_id = String::new(); + let mut bill_change_id = String::new(); + + // Obtain share notification change ids for all accounts + for (change_id, client) in [ + (&mut john_change_id, john), + (&mut jane_change_id, jane), + (&mut bill_change_id, bill), + ] { + let response = client + .jmap_get( + MethodObject::CalendarEventNotification, + [CalendarEventNotificationProperty::Id], + Vec::<&str>::new(), + ) + .await; + response.list_array().assert_is_equal(json!([])); + *change_id = response.state().to_string(); + } + + // Create test calendars + let response = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test Calendar", + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let john_calendar_id = response.created(0).id().to_string(); + + // Sent invitation to Jane and Bill + let john_event = test_event(); + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [john_event.clone().with_property( + JSCalendarProperty::::CalendarIds, + [john_calendar_id.as_str()].into_jmap_set(), + )], + [("sendSchedulingMessages", true)], + ) + .await; + let john_event_id = response.created(0).id().to_string(); + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify Jane and Bill received the share notification + let mut jane_event_id = String::new(); + let mut bill_event_id = String::new(); + for (change_id, event_id, client) in [ + (&mut jane_change_id, &mut jane_event_id, jane), + (&mut bill_change_id, &mut bill_event_id, bill), + ] { + // Obtain changes + let response = client + .jmap_changes(MethodObject::CalendarEventNotification, &change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + *change_id = response.new_state().to_string(); + let notification_id = changes[0].as_created(); + + // Obtain and verify notification + let response = client + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::Created, + CalendarEventNotificationProperty::ChangedBy, + CalendarEventNotificationProperty::Comment, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::CalendarEventId, + CalendarEventNotificationProperty::IsDraft, + CalendarEventNotificationProperty::Event, + CalendarEventNotificationProperty::EventPatch, + ], + [notification_id], + ) + .await; + let notification = &response.list()[0]; + *event_id = notification.text_field("calendarEventId").to_string(); + notification.assert_is_equal(json!({ + "id": ¬ification_id, + "created": ¬ification.text_field("created"), + "changedBy": { + "name": "John Doe", + "email": "jdoe@example.com", + "principalId": &john_id + }, + "type": "created", + "calendarEventId": event_id, + "isDraft": false, + "event": john_event + .clone() + .with_property("sequence", 1) + .with_property( + "updated", + notification + .text_field("event/updated") + ) + })); + + // Verify the event exists + let response = client + .jmap_get( + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [&event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": &event_id, + "title": "Lunch" + })); + } + + // Jane and Bill accept the invitation + let response = jane + .jmap_update( + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus": + "accepted"}), + )], + [("sendSchedulingMessages", true)], + ) + .await; + response.updated(&jane_event_id); + let response = bill + .jmap_update( + MethodObject::CalendarEvent, + [( + &bill_event_id, + json!({ + "participants/86720268-d67c-58c3-9217-03df7d7ee4d8/participationStatus": + "accepted"}), + )], + [("sendSchedulingMessages", true)], + ) + .await; + response.updated(&bill_event_id); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify John received two share notifications + let response = john + .jmap_changes(MethodObject::CalendarEventNotification, &john_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 2); + for (i, change) in changes.into_iter().enumerate() { + let notification_id = change.as_created(); + + // Obtain and verify notification + let response = john + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::ChangedBy, + CalendarEventNotificationProperty::Comment, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::CalendarEventId, + CalendarEventNotificationProperty::IsDraft, + ], + [notification_id], + ) + .await; + let changed_by = if i == 0 { + json!({ + "name": "Jane Smith", + "email": "jane.smith@example.com", + "principalId": &jane_id, + }) + } else { + json!({ + "name": "Bill Foobar", + "email": "bill@example.com", + "principalId": &bill_id, + }) + }; + + response.list()[0].assert_is_equal(json!({ + "id": ¬ification_id, + "changedBy": changed_by, + "type": "updated", + "calendarEventId": &john_event_id, + "isDraft": false + })); + } + + // Verify the event was updated + let response = john + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::Participants, + ], + [&john_event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "@type": "Participant", + "roles": { + "attendee": true, + "chair": true + }, + "participationStatus": "accepted" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "roles": { + "attendee": true + }, + "participationStatus": "accepted", + "kind": "individual" + }, + "86720268-d67c-58c3-9217-03df7d7ee4d8": { + "calendarAddress": "mailto:bill@example.com", + "@type": "Participant", + "roles": { + "attendee": true + }, + "kind": "individual", + "participationStatus": "accepted" + } + }, + "title": "Lunch", + "id": &john_event_id + })); + + // Jane later declines the invitation + let response = jane + .jmap_update( + MethodObject::CalendarEvent, + [( + &jane_event_id, + json!({ + "participants/a0171748-fe8d-57d8-879e-56036a5251d1/participationStatus": + "declined"}), + )], + [("sendSchedulingMessages", true)], + ) + .await; + response.updated(&jane_event_id); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Make sure John received the update + let response = john + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::Participants, + ], + [&john_event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "@type": "Participant", + "roles": { + "attendee": true, + "chair": true + }, + "participationStatus": "accepted" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "roles": { + "attendee": true + }, + "participationStatus": "declined", + "kind": "individual" + }, + "86720268-d67c-58c3-9217-03df7d7ee4d8": { + "calendarAddress": "mailto:bill@example.com", + "@type": "Participant", + "roles": { + "attendee": true + }, + "kind": "individual", + "participationStatus": "accepted" + } + }, + "title": "Lunch", + "id": &john_event_id + })); + + // John deletes the event + let response = john + .jmap_destroy( + MethodObject::CalendarEvent, + [&john_event_id], + [("sendSchedulingMessages", true)], + ) + .await; + assert_eq!(response.destroyed().collect::>(), [&john_event_id]); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Verify that only Bill received the cancellation + let response = jane + .jmap_changes(MethodObject::CalendarEventNotification, &jane_change_id) + .await; + assert_eq!(response.changes().next(), None); + let response = bill + .jmap_changes(MethodObject::CalendarEventNotification, &bill_change_id) + .await; + let changes = response.changes().collect::>(); + assert_eq!(changes.len(), 1); + let notification_id = changes[0].as_created(); + let response = bill + .jmap_get( + MethodObject::CalendarEventNotification, + [ + CalendarEventNotificationProperty::Id, + CalendarEventNotificationProperty::ChangedBy, + CalendarEventNotificationProperty::Comment, + CalendarEventNotificationProperty::Type, + CalendarEventNotificationProperty::CalendarEventId, + CalendarEventNotificationProperty::IsDraft, + ], + [notification_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": ¬ification_id, + "changedBy": { + "name": "John Doe", + "email": "jdoe@example.com", + "principalId": &john_id + }, + "type": "updated", + "calendarEventId": &bill_event_id, + "isDraft": false + })); + + // Verify Bill's event was updated + let response = bill + .jmap_get( + MethodObject::CalendarEvent, + [ + JSCalendarProperty::::Id, + JSCalendarProperty::Title, + JSCalendarProperty::Status, + ], + [&bill_event_id], + ) + .await; + response.list()[0].assert_is_equal(json!({ + "id": &bill_event_id, + "title": "Lunch", + "status": "cancelled" + })); + + // Cleanup + for client in [john, jane, bill] { + client.destroy_all_calendars().await; + client.destroy_all_event_notifications().await; + params.destroy_all_mailboxes(client).await; + } + params.assert_is_empty().await; +} + +fn test_event() -> Value { + json!({ + "uid": "9263504FD3AD", + "title": "Lunch", + "timeZone": "Europe/London", + "start": DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339().trim_end_matches("Z").to_string(), + "duration": "PT1H", + "freeBusyStatus": "busy", + "updated": "2009-06-02T17:00:00Z", + "sequence": 0, + "@type": "Event", + "participants": { + "8584f8f9-5414-55e3-8a1c-ad6fc2f3ffb6": { + "calendarAddress": "mailto:jdoe@example.com", + "participationStatus": "accepted", + "roles": { + "attendee": true, + "chair": true + }, + "@type": "Participant" + }, + "a0171748-fe8d-57d8-879e-56036a5251d1": { + "calendarAddress": "mailto:jane.smith@example.com", + "@type": "Participant", + "roles": { + "attendee": true + }, + "participationStatus": "needs-action", + "kind": "individual" + }, + "86720268-d67c-58c3-9217-03df7d7ee4d8": { + "calendarAddress": "mailto:bill@example.com", + "participationStatus": "needs-action", + "@type": "Participant", + "roles": { + "attendee": true + }, + "kind": "individual" + } + }, + "organizerCalendarAddress": "mailto:jdoe@example.com" + }) } diff --git a/tests/src/jmap/contacts/acl.rs b/tests/src/jmap/contacts/acl.rs index b171bb65..9636fbbf 100644 --- a/tests/src/jmap/contacts/acl.rs +++ b/tests/src/jmap/contacts/acl.rs @@ -14,7 +14,7 @@ use serde_json::json; use types::id::Id; pub async fn test(params: &mut JMAPTest) { - println!("Running contacts ACL tests..."); + 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(); @@ -27,6 +27,7 @@ pub async fn test(params: &mut JMAPTest) { [json!({ "name": "Test #1", })], + Vec::<(&str, &str)>::new(), ) .await; let john_book_id = response.created(0).id().to_string(); @@ -42,6 +43,7 @@ pub async fn test(params: &mut JMAPTest) { &john_book_id: true }, })], + Vec::<(&str, &str)>::new(), ) .await .created(0) @@ -53,6 +55,7 @@ pub async fn test(params: &mut JMAPTest) { [json!({ "name": "Test #1", })], + Vec::<(&str, &str)>::new(), ) .await; let jane_book_id = response.created(0).id().to_string(); @@ -68,6 +71,7 @@ pub async fn test(params: &mut JMAPTest) { &jane_book_id: true }, })], + Vec::<(&str, &str)>::new(), ) .await .created(0) @@ -396,6 +400,7 @@ pub async fn test(params: &mut JMAPTest) { [json!({ "name": "A new shared address book", })], + Vec::<(&str, &str)>::new() ) .await .not_created(0) diff --git a/tests/src/jmap/contacts/addressbook.rs b/tests/src/jmap/contacts/addressbook.rs index 3de38a1d..737dd929 100644 --- a/tests/src/jmap/contacts/addressbook.rs +++ b/tests/src/jmap/contacts/addressbook.rs @@ -10,7 +10,7 @@ use serde_json::json; use crate::jmap::{ChangeType, JMAPTest, JmapUtils}; pub async fn test(params: &mut JMAPTest) { - println!("Running Address book tests..."); + println!("Running AddressBook tests..."); let account = params.account("jdoe@example.com"); // Make sure the default address book exists @@ -55,6 +55,7 @@ pub async fn test(params: &mut JMAPTest) { "isSubscribed": true })], + Vec::<(&str, &str)>::new(), ) .await .created(0) @@ -173,6 +174,7 @@ pub async fn test(params: &mut JMAPTest) { } } })], + Vec::<(&str, &str)>::new(), ) .await .created(0) diff --git a/tests/src/jmap/contacts/contact.rs b/tests/src/jmap/contacts/contact.rs index 294463ac..e7f4724d 100644 --- a/tests/src/jmap/contacts/contact.rs +++ b/tests/src/jmap/contacts/contact.rs @@ -17,7 +17,7 @@ use serde_json::{Value, json}; use types::{collection::SyncCollection, id::Id}; pub async fn test(params: &mut JMAPTest) { - println!("Running contacts tests..."); + println!("Running Contact Card tests..."); let account = params.account("jdoe@example.com"); // Create test address books @@ -32,6 +32,7 @@ pub async fn test(params: &mut JMAPTest) { "name": "Test #2", }), ], + Vec::<(&str, &str)>::new(), ) .await; let book1_id = response.created(0).id().to_string(); @@ -69,6 +70,7 @@ pub async fn test(params: &mut JMAPTest) { carlos_contact.clone(), acme_contact.clone(), ], + Vec::<(&str, &str)>::new(), ) .await; let sarah_contact_id = response.created(0).id().to_string(); @@ -120,6 +122,7 @@ pub async fn test(params: &mut JMAPTest) { }, "addressBookIds": {}, }),], + Vec::<(&str, &str)>::new() ) .await .not_created(0) @@ -141,6 +144,7 @@ pub async fn test(params: &mut JMAPTest) { &book1_id: true }, }),], + Vec::<(&str, &str)>::new() ) .await .not_created(0) diff --git a/tests/src/jmap/files/acl.rs b/tests/src/jmap/files/acl.rs index 4ad4c02c..b60a3970 100644 --- a/tests/src/jmap/files/acl.rs +++ b/tests/src/jmap/files/acl.rs @@ -12,7 +12,7 @@ use jmap_proto::{ use serde_json::json; pub async fn test(params: &mut JMAPTest) { - println!("Running file storage ACL tests..."); + println!("Running File Storage 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(); @@ -25,6 +25,7 @@ pub async fn test(params: &mut JMAPTest) { [json!({ "name": "Test #1", })], + Vec::<(&str, &str)>::new(), ) .await; let john_folder_id = response.created(0).id().to_string(); @@ -313,6 +314,7 @@ pub async fn test(params: &mut JMAPTest) { [json!({ "name": "A new shared folder", })], + Vec::<(&str, &str)>::new() ) .await .not_created(0) diff --git a/tests/src/jmap/files/node.rs b/tests/src/jmap/files/node.rs index 96263e9e..e3807b3a 100644 --- a/tests/src/jmap/files/node.rs +++ b/tests/src/jmap/files/node.rs @@ -10,7 +10,7 @@ use jmap_proto::{object::file_node::FileNodeProperty, request::method::MethodObj use serde_json::json; pub async fn test(params: &mut JMAPTest) { - println!("Running file storage tests..."); + println!("Running File Storage tests..."); let account = params.account("jdoe@example.com"); // Obtain change id @@ -42,6 +42,7 @@ pub async fn test(params: &mut JMAPTest) { "parentId": "#i1", }), ], + Vec::<(&str, &str)>::new(), ) .await; let root_folder_id = response.created(0).id().to_string(); @@ -190,6 +191,7 @@ pub async fn test(params: &mut JMAPTest) { "name": "..", }), ], + Vec::<(&str, &str)>::new(), ) .await; assert_eq!( diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 116b3eac..3ccd91fc 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -107,15 +107,21 @@ async fn jmap_tests() { server::purge::test(&mut params).await; server::enterprise::test(&mut params).await;*/ - /*contacts::addressbook::test(&mut params).await; + contacts::addressbook::test(&mut params).await; contacts::contact::test(&mut params).await; contacts::acl::test(&mut params).await; files::node::test(&mut params).await; - files::acl::test(&mut params).await;*/ + files::acl::test(&mut params).await; - //calendar::calendars::test(&mut params).await; + calendar::calendars::test(&mut params).await; calendar::event::test(&mut params).await; + calendar::notification::test(&mut params).await; + calendar::identity::test(&mut params).await; + calendar::acl::test(&mut params).await; + + principal::get::test(&mut params).await; + principal::availability::test(&mut params).await; if delete { params.temp_dir.delete(); @@ -602,8 +608,10 @@ impl Account { &self, object: impl Display, items: impl IntoIterator, + arguments: impl IntoIterator)>, ) -> JmapResponse { - self.jmap_create_account(self, object, items).await + self.jmap_create_account(self, object, items, arguments) + .await } pub async fn jmap_create_account( @@ -611,18 +619,30 @@ impl Account { account: &Account, object: impl Display, items: impl IntoIterator, + arguments: 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 + let create = items + .into_iter() + .enumerate() + .map(|(i, item)| (format!("i{i}"), item)) + .collect::>(); + let arguments = [ + ( + "accountId".to_string(), + Value::String(account.id_string().to_string()), + ), + ("create".to_string(), Value::Object(create)), + ] + .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_update( @@ -782,6 +802,38 @@ impl Account { ) } + pub async fn jmap_session_object(&self) -> JmapResponse { + let mut headers = header::HeaderMap::new(); + + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!( + "Basic {}", + general_purpose::STANDARD.encode(format!("{}:{}", self.name(), self.secret())) + )) + .unwrap(), + ); + + JmapResponse( + serde_json::from_slice( + &reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .timeout(Duration::from_millis(1000)) + .default_headers(headers) + .build() + .unwrap() + .get("https://127.0.0.1:8899/jmap/session") + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(), + ) + .unwrap(), + ) + } + pub async fn destroy_all_addressbooks(&self) { self.jmap_method_calls(json!([[ "AddressBook/get", @@ -835,6 +887,32 @@ impl Account { ])) .await; } + + pub async fn destroy_all_event_notifications(&self) { + self.jmap_method_calls(json!([[ + "CalendarEventNotification/get", + { + "ids" : (), + "properties" : [ + "id" + ] + }, + "R1" + ], + [ + "CalendarEventNotification/set", + { + "#destroy" : { + "resultOf": "R1", + "name": "CalendarEventNotification/get", + "path": "/list/*/id" + } + }, + "R2" + ] + ])) + .await; + } } impl JmapResponse { @@ -992,7 +1070,7 @@ impl JmapUtils for Value { fn assert_is_equal(&self, expected: Value) { if self != &expected { panic!( - "Values are not equal:\nself: {}\nexpected: {}", + "Values are not equal:\ngot: {}\nexpected: {}", serde_json::to_string_pretty(self).unwrap(), serde_json::to_string_pretty(&expected).unwrap() ); @@ -1610,6 +1688,9 @@ vrfy = true [spam-filter] enable = true +[sharing] +allow-directory-query = true + [tracer.console] type = "console" level = "{LEVEL}" diff --git a/tests/src/jmap/principal/availability.rs b/tests/src/jmap/principal/availability.rs index f6336379..142583d7 100644 --- a/tests/src/jmap/principal/availability.rs +++ b/tests/src/jmap/principal/availability.rs @@ -4,11 +4,252 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::jmap::{JMAPTest, JmapUtils}; +use crate::jmap::{IntoJmapSet, JMAPTest, JmapUtils, calendar::event::*}; +use calcard::jscalendar::JSCalendarProperty; use jmap_proto::request::method::MethodObject; use serde_json::json; +use types::id::Id; pub async fn test(params: &mut JMAPTest) { - println!("Running tests..."); - let account = params.account("jdoe@example.com"); + println!("Running Principal Availability 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 calendars + let response = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Test Calendar", + "includeInAvailability": "all" + })], + Vec::<(&str, &str)>::new(), + ) + .await; + let calendar1_id = response.created(0).id().to_string(); + + // Create test events + let event_1 = test_jscalendar_1().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ); + let event_2 = test_jscalendar_2().with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ); + let event_3 = test_jscalendar_3() + .with_property( + JSCalendarProperty::::CalendarIds, + [calendar1_id.as_str()].into_jmap_set(), + ) + .with_property( + JSCalendarProperty::::Participants, + json!({ + "3f5bc8c0-c722-5345-b7d9-5a899db08a30": { + "calendarAddress": "mailto:jdoe@example.com", + "@type": "Participant", + "roles": { + "attendee": true, + "chair": true + }, + "participationStatus": "accepted" + } + }), + ); + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [event_1, event_2, event_3], + Vec::<(&str, &str)>::new(), + ) + .await; + let _event_1_id = response.created(0).id().to_string(); + let _event_2_id = response.created(1).id().to_string(); + let event_3_id = response.created(2).id().to_string(); + + // Jane should not have access to John's availability + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([])); + + // Grant Jane free/busy access + john.jmap_update( + MethodObject::Calendar, + [( + &calendar1_id, + json!({ + "shareWith": { + &jane_id : { + "mayReadFreeBusy": true, + } + } + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&calendar1_id); + + // Jane should see John's availability now + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([ + { + "utcStart": "2006-01-02T15:00:00Z", + "utcEnd": "2006-01-02T16:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-02T17:00:00Z", + "utcEnd": "2006-01-02T18:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-03T17:00:00Z", + "utcEnd": "2006-01-03T18:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-04T15:00:00Z", + "utcEnd": "2006-01-04T16:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-04T19:00:00Z", + "utcEnd": "2006-01-04T20:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-05T17:00:00Z", + "utcEnd": "2006-01-05T18:00:00Z", + "busyStatus": "confirmed", + "event": null + }, + { + "utcStart": "2006-01-06T19:00:00Z", + "utcEnd": "2006-01-06T20:00:00Z", + "busyStatus": "confirmed", + "event": null + } + ])); + + // Update availability to none + john.jmap_update( + MethodObject::Calendar, + [( + &calendar1_id, + json!({ + "includeInAvailability": "none" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&calendar1_id); + + // Jane should not see any events now + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([])); + + // Update availability to attending + john.jmap_update( + MethodObject::Calendar, + [( + &calendar1_id, + json!({ + "includeInAvailability": "attending" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&calendar1_id); + + // Jane should only see events where John is attending + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([ + { + "utcStart": "2006-01-04T15:00:00Z", + "utcEnd": "2006-01-04T16:00:00Z", + "busyStatus": "confirmed", + "event": null + } + ])); + + // Update attending event to not attending + john.jmap_update( + MethodObject::CalendarEvent, + [( + &event_3_id, + json!({ + "participants/3f5bc8c0-c722-5345-b7d9-5a899db08a30/participationStatus": "declined" + }), + )], + Vec::<(&str, &str)>::new(), + ) + .await + .updated(&event_3_id); + + // Jane should not see any events now + let response = jane + .jmap_method_calls(json!([[ + "Principal/getAvailability", + { + "id": &john_id, + "utcStart": "2006-01-01T00:00:00Z", + "utcEnd": "2006-01-08T00:00:00Z", + }, + "0" + ]])) + .await; + response.list_array().assert_is_equal(json!([])); + + // Cleanup + john.destroy_all_calendars().await; + params.assert_is_empty().await; } diff --git a/tests/src/jmap/principal/get.rs b/tests/src/jmap/principal/get.rs index f6336379..8709be14 100644 --- a/tests/src/jmap/principal/get.rs +++ b/tests/src/jmap/principal/get.rs @@ -5,10 +5,416 @@ */ use crate::jmap::{JMAPTest, JmapUtils}; -use jmap_proto::request::method::MethodObject; +use jmap_proto::{object::principal::PrincipalProperty, request::method::MethodObject}; use serde_json::json; pub async fn test(params: &mut JMAPTest) { - println!("Running tests..."); - let account = params.account("jdoe@example.com"); + println!("Running Principal get/query tests..."); + let john = params.account("jdoe@example.com"); + let jane = params.account("jane.smith@example.com"); + let bill = params.account("bill@example.com"); + let sales = params.account("sales@example.com"); + + let john_id = john.id_string(); + let jane_id = jane.id_string(); + let bill_id = bill.id_string(); + let sales_id = sales.id_string(); + + // Validate session object capabilities + let response = john.jmap_session_object().await.into_inner(); + response.assert_is_equal(json!({ + "capabilities": { + "urn:ietf:params:jmap:core": { + "maxSizeUpload": 5000000, + "maxConcurrentUpload": 4, + "maxSizeRequest": 10000000, + "maxConcurrentRequests": 8, + "maxCallsInRequest": 16, + "maxObjectsInGet": 100000, + "maxObjectsInSet": 100000, + "collationAlgorithms": [ + "i;ascii-numeric", + "i;ascii-casemap", + "i;unicode-casemap" + ] + }, + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:calendars:parse": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:contacts:parse": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:availability": {}, + "urn:ietf:params:jmap:submission": {}, + "urn:ietf:params:jmap:vacationresponse": {}, + "urn:ietf:params:jmap:sieve": { + "implementation": "Stalwart v1.0.0" + }, + "urn:ietf:params:jmap:blob": {}, + "urn:ietf:params:jmap:quota": {}, + "urn:ietf:params:jmap:websocket": { + "url": "wss://127.0.0.1:8899/jmap/ws", + "supportsPush": true + } + }, + "accounts": { + john_id: { + "name": "jdoe@example.com", + "isPersonal": true, + "isReadOnly": false, + "accountCapabilities": { + "urn:ietf:params:jmap:mail": { + "maxMailboxesPerEmail": null, + "maxMailboxDepth": 10, + "maxSizeMailboxName": 255, + "maxSizeAttachmentsPerEmail": 50000000, + "emailQuerySortOptions": [ + "receivedAt", + "size", + "from", + "to", + "subject", + "sentAt", + "hasKeyword", + "allInThreadHaveKeyword", + "someInThreadHaveKeyword" + ], + "mayCreateTopLevelMailbox": true + }, + "urn:ietf:params:jmap:submission": { + "maxDelayedSend": 2592000, + "submissionExtensions": { + "FUTURERELEASE": [], + "SIZE": [], + "DSN": [], + "DELIVERYBY": [], + "MT-PRIORITY": [ + "MIXER" + ], + "REQUIRETLS": [] + } + }, + "urn:ietf:params:jmap:vacationresponse": {}, + "urn:ietf:params:jmap:contacts": { + "maxAddressBooksPerCard": null, + "mayCreateAddressBook": true + }, + "urn:ietf:params:jmap:contacts:parse": {}, + "urn:ietf:params:jmap:calendars": { + "maxCalendarsPerEvent": null, + "minDateTime": "0001-01-01T00:00:00Z", + "maxDateTime": "65534-12-31T23:59:59Z", + "maxExpandedQueryDuration": "P52W1D", + "maxParticipantsPerEvent": 20, + "mayCreateCalendar": true + }, + "urn:ietf:params:jmap:calendars:parse": {}, + "urn:ietf:params:jmap:websocket": {}, + "urn:ietf:params:jmap:sieve": { + "maxSizeScriptName": 512, + "maxSizeScript": 1048576, + "maxNumberScripts": 256, + "maxNumberRedirects": 1, + "sieveExtensions": [ + "body", + "comparator-elbonia", + "comparator-i;ascii-casemap", + "comparator-i;ascii-numeric", + "comparator-i;octet", + "convert", + "copy", + "date", + "duplicate", + "editheader", + "enclose", + "encoded-character", + "enotify", + "envelope", + "envelope-deliverby", + "envelope-dsn", + "environment", + "ereject", + "extlists", + "extracttext", + "fcc", + "fileinto", + "foreverypart", + "ihave", + "imap4flags", + "imapsieve", + "include", + "index", + "mailbox", + "mailboxid", + "mboxmetadata", + "mime", + "redirect-deliverby", + "redirect-dsn", + "regex", + "reject", + "relational", + "replace", + "servermetadata", + "spamtest", + "spamtestplus", + "special-use", + "subaddress", + "vacation", + "vacation-seconds", + "variables", + "virustest" + ], + "notificationMethods": [ + "mailto" + ], + "externalLists": null + }, + "urn:ietf:params:jmap:blob": { + "maxSizeBlobSet": 7499488, + "maxDataSources": 16, + "supportedTypeNames": [ + "Email", + "Thread", + "SieveScript" + ], + "supportedDigestAlgorithms": [ + "sha", + "sha-256", + "sha-512" + ] + }, + "urn:ietf:params:jmap:quota": {}, + "urn:ietf:params:jmap:principals": { + "currentUserPrincipalId": john_id + }, + "urn:ietf:params:jmap:principals:availability": { + "maxAvailabilityDuration": "P52W1D", + }, + "urn:ietf:params:jmap:filenode": { + "maxFileNodeDepth": null, + "maxSizeFileNodeName": 255, + "fileNodeQuerySortOptions": [], + "mayCreateTopLevelFileNode": true + } + } + } + }, + "primaryAccounts": { + "urn:ietf:params:jmap:mail": john_id, + "urn:ietf:params:jmap:submission": john_id, + "urn:ietf:params:jmap:vacationresponse": john_id, + "urn:ietf:params:jmap:contacts": john_id, + "urn:ietf:params:jmap:contacts:parse": john_id, + "urn:ietf:params:jmap:calendars": john_id, + "urn:ietf:params:jmap:calendars:parse": john_id, + "urn:ietf:params:jmap:websocket": john_id, + "urn:ietf:params:jmap:sieve": john_id, + "urn:ietf:params:jmap:blob": john_id, + "urn:ietf:params:jmap:quota": john_id, + "urn:ietf:params:jmap:principals": john_id, + "urn:ietf:params:jmap:principals:availability": john_id, + "urn:ietf:params:jmap:filenode": john_id + }, + "username": "jdoe@example.com", + "apiUrl": "https://127.0.0.1:8899/jmap/", + "downloadUrl": + "https://127.0.0.1:8899/jmap/download/{accountId}/{blobId}/{name}?accept={type}", + "uploadUrl": + "https://127.0.0.1:8899/jmap/upload/{accountId}/", + "eventSourceUrl": + "https://127.0.0.1:8899/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}", + "state": response.text_field("state") + })); + + // Obtain principal ids for Jane, Bill and the sales group + let response = john + .jmap_query( + MethodObject::Principal, + [("email", "john.doe@example.com")], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [john_id]); + let response = john + .jmap_query( + MethodObject::Principal, + [("name", "bill@example.com")], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [bill_id]); + let response = john + .jmap_query( + MethodObject::Principal, + [("accountIds", [jane_id])], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [jane_id]); + let response = john + .jmap_query( + MethodObject::Principal, + [("text", "sales group")], + ["name"], + Vec::<(&str, &str)>::new(), + ) + .await; + assert_eq!(response.ids().collect::>(), [sales_id]); + + // Validate principal contents + let response = john + .jmap_get( + MethodObject::Principal, + [ + PrincipalProperty::Id, + PrincipalProperty::Type, + PrincipalProperty::Email, + PrincipalProperty::Description, + PrincipalProperty::Name, + PrincipalProperty::Timezone, + PrincipalProperty::Capabilities, + PrincipalProperty::Accounts, + ], + [john_id, jane_id, bill_id, sales_id], + ) + .await; + let list = response.list(); + assert_eq!(list.len(), 4); + + list[0].assert_is_equal(json!({ + "id": john_id, + "type": "individual", + "email": "jdoe@example.com", + "description": "John Doe", + "name": "jdoe@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + john_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": john_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:jdoe@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": john_id, + "principalId": john_id + } + } + } + })); + list[1].assert_is_equal(json!({ + "id": jane_id, + "type": "individual", + "email": "jane.smith@example.com", + "description": "Jane Smith", + "name": "jane.smith@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + jane_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": jane_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:jane.smith@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": jane_id, + "principalId": jane_id + } + } + } + })); + list[2].assert_is_equal(json!({ + "id": bill_id, + "type": "individual", + "email": "bill@example.com", + "description": "Bill Foobar", + "name": "bill@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + bill_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": bill_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:bill@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": bill_id, + "principalId": bill_id + } + } + } + })); + list[3].assert_is_equal(json!({ + "id": sales_id, + "type": "group", + "email": "sales@example.com", + "description": "Sales Group", + "name": "sales@example.com", + "timezone": null, + "capabilities": { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": {}, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {} + }, + "accounts": { + sales_id: { + "urn:ietf:params:jmap:mail": {}, + "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:calendars": { + "accountId": sales_id, + "mayGetAvailability": true, + "mayShareWith": true, + "calendarAddress": "mailto:sales@example.com" + }, + "urn:ietf:params:jmap:filenode": {}, + "urn:ietf:params:jmap:principals": {}, + "urn:ietf:params:jmap:principals:owner": { + "accountIdForPrincipal": sales_id, + "principalId": sales_id + } + } + } + })); } diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index ac67acb3..73ccd462 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -1208,6 +1208,9 @@ auto-add = true [dav.collection] assisted-discovery = {ASSISTED_DISCOVERY} +[sharing] +allow-directory-query = true + [store."auth"] type = "sqlite" path = "{TMP}/auth.db"