Skip to content

Commit 02205c3

Browse files
fixup! fix: iTipBroker message generation and testing
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent 9592e74 commit 02205c3

File tree

2 files changed

+192
-4
lines changed

2 files changed

+192
-4
lines changed

apps/dav/lib/CalDAV/TipBroker.php

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ protected function processMessageCancel(Message $itipMessage, ?VCalendar $existi
8686
* We will detect which attendees got added, which got removed and create
8787
* specific messages for these situations.
8888
*
89-
* @return array
89+
* @return array<int,Message>
9090
*/
9191
protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) {
9292

@@ -117,7 +117,7 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
117117
$organizerName = $eventInfo['organizerName'];
118118
}
119119
// detect if the singleton or recurring base instance was converted to non-scheduling
120-
if (count($eventInfo['instances']) === 0 && count($oldEventInfo['instances'])> 0) {
120+
if (count($eventInfo['instances']) === 0 && count($oldEventInfo['instances']) > 0) {
121121
foreach ($oldEventInfo['attendees'] as $attendee) {
122122
$messages[] = $this->generateMessage(
123123
$oldEventInfo['instances'], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template
@@ -135,13 +135,15 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
135135
return $messages;
136136
}
137137
// detect if a new cancelled instance was created
138+
$cancelledNewInstances = [];
138139
if (isset($oldEventInfo['instances'])) {
139140
$instancesDelta = array_diff_key($eventInfo['instances'], $oldEventInfo['instances']);
140141
foreach ($instancesDelta as $id => $instance) {
141142
if ($instance->STATUS?->getValue() === 'CANCELLED') {
143+
$cancelledNewInstances[] = $id;
142144
foreach ($eventInfo['attendees'] as $attendee) {
143145
$messages[] = $this->generateMessage(
144-
[$instance], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template
146+
[$id => $instance], $organizerHref, $organizerName, $attendee, $objectId, $objectType, $objectSequence, 'CANCEL', $template
145147
);
146148
}
147149
}
@@ -155,6 +157,18 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
155157
)
156158
);
157159
foreach ($attendees as $attendee) {
160+
// Skip organizer
161+
if ($attendee === $organizerHref) {
162+
continue;
163+
}
164+
165+
// Skip if SCHEDULE-AGENT=CLIENT (respect RFC 6638)
166+
if ($this->scheduleAgentServerRules
167+
&& isset($eventInfo['attendees'][$attendee]['scheduleAgent'])
168+
&& strtoupper($eventInfo['attendees'][$attendee]['scheduleAgent']) === 'CLIENT') {
169+
continue;
170+
}
171+
158172
// detect if attendee was removed and send cancel message
159173
if (!isset($eventInfo['attendees'][$attendee]) && isset($oldEventInfo['attendees'][$attendee])) {
160174
//get all instances of the attendee was removed from.
@@ -166,6 +180,39 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
166180
}
167181
// otherwise any created or modified instances will be sent as REQUEST
168182
$instances = array_intersect_key($eventInfo['instances'], array_flip(array_keys($eventInfo['attendees'][$attendee]['instances'])));
183+
184+
// Remove already-cancelled new instances from REQUEST
185+
if (!empty($cancelledNewInstances)) {
186+
$instances = array_diff_key($instances, array_flip($cancelledNewInstances));
187+
}
188+
189+
// Skip if no instances left to send
190+
if (empty($instances)) {
191+
continue;
192+
}
193+
194+
// Add EXDATE for instances the attendee is NOT part of (only for recurring events with master)
195+
if (isset($instances['master']) && count($eventInfo['instances']) > 1) {
196+
$masterInstance = clone $instances['master'];
197+
$excludedDates = [];
198+
199+
foreach ($eventInfo['instances'] as $instanceId => $instance) {
200+
if ($instanceId !== 'master' && !isset($eventInfo['attendees'][$attendee]['instances'][$instanceId])) {
201+
$excludedDates[] = $instance->{'RECURRENCE-ID'}->getValue();
202+
}
203+
}
204+
205+
if (!empty($excludedDates)) {
206+
if (isset($masterInstance->EXDATE)) {
207+
$currentExdates = $masterInstance->EXDATE->getParts();
208+
$masterInstance->EXDATE->setParts(array_merge($currentExdates, $excludedDates));
209+
} else {
210+
$masterInstance->EXDATE = $excludedDates;
211+
}
212+
$instances['master'] = $masterInstance;
213+
}
214+
}
215+
169216
$messages[] = $this->generateMessage(
170217
$instances, $organizerHref, $organizerName, $eventInfo['attendees'][$attendee], $objectId, $objectType, $objectSequence, 'REQUEST', $template
171218
);
@@ -174,6 +221,26 @@ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo,
174221
return $messages;
175222
}
176223

224+
/**
225+
* Generates an iTip message for a specific attendee
226+
*
227+
* @param array<string, Component> $instances Array of event instances to include, keyed by instance ID:
228+
* - 'master' => Component: The master/base event
229+
* - '{RECURRENCE-ID}' => Component: Exception instances
230+
* @param string $organizerHref The organizer's calendar-user address (e.g., 'mailto:user@example.com')
231+
* @param string|null $organizerName The organizer's display name
232+
* @param array $attendee The attendee information containing:
233+
* - 'href' (string): The attendee's calendar-user address
234+
* - 'name' (string): The attendee's display name
235+
* - 'scheduleAgent' (string|null): SCHEDULE-AGENT parameter
236+
* - 'instances' (array): Instances this attendee is part of
237+
* @param string $objectId The UID of the event
238+
* @param string $objectType The component type ('VEVENT', 'VTODO', etc.)
239+
* @param int $objectSequence The sequence number of the event
240+
* @param string $method The iTip method ('REQUEST', 'CANCEL', 'REPLY', etc.)
241+
* @param VCalendar $template The template calendar object (without event components)
242+
* @return Message The generated iTip message ready to be sent
243+
*/
177244
protected function generateMessage(
178245
array $instances,
179246
string $organizerHref,
@@ -198,7 +265,7 @@ protected function generateMessage(
198265
foreach ($instances as $instance) {
199266
$vObject->add($this->componentSanitizeScheduling(clone $instance));
200267
}
201-
268+
202269
$message = new Message();
203270
$message->method = $method;
204271
$message->uid = $objectId;

apps/dav/tests/unit/CalDAV/TipBrokerTest.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,4 +458,125 @@ public function testParseEventForOrganizerModifyInstanceRemoveAttendee(): void {
458458

459459
}
460460

461+
/**
462+
* Tests user deleting master instance of recurring event
463+
*/
464+
public function testParseEventForOrganizerDeleteMasterInstance(): void {
465+
// construct calendar with recurring event
466+
$originalCalendar = clone $this->vCalendar2a;
467+
$originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]);
468+
// delete the master instance (convert to non-scheduling)
469+
$mutatedCalendar = clone $this->vCalendar2a;
470+
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
471+
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
472+
$mutatedCalendar->VEVENT->remove('ORGANIZER');
473+
$mutatedCalendar->VEVENT->remove('ATTENDEE');
474+
$mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]);
475+
// test iTip generation
476+
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]);
477+
$this->assertCount(1, $messages);
478+
$this->assertEquals('CANCEL', $messages[0]->method);
479+
$this->assertEquals(2, $messages[0]->sequence);
480+
$this->assertEquals($originalCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
481+
$this->assertEquals($originalCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
482+
}
483+
484+
/**
485+
* Tests user adding EXDATE to master instance
486+
*/
487+
public function testParseEventForOrganizerAddExdate(): void {
488+
// construct calendar with recurring event
489+
$originalCalendar = clone $this->vCalendar2a;
490+
$originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]);
491+
// add EXDATE to exclude specific occurrences
492+
$mutatedCalendar = clone $this->vCalendar2a;
493+
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
494+
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
495+
$mutatedCalendar->VEVENT->add('EXDATE', ['20240715T080000', '20240722T080000'], ['TZID' => 'America/Toronto']);
496+
$mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]);
497+
// test iTip generation
498+
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]);
499+
$this->assertCount(1, $messages);
500+
$this->assertEquals('REQUEST', $messages[0]->method);
501+
$this->assertEquals(2, $messages[0]->sequence);
502+
$this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
503+
$this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
504+
// verify EXDATE is present in the message
505+
$this->assertTrue(isset($messages[0]->message->VEVENT->EXDATE));
506+
$exdates = $messages[0]->message->VEVENT->EXDATE->getParts();
507+
$this->assertContains('20240715T080000', $exdates);
508+
$this->assertContains('20240722T080000', $exdates);
509+
}
510+
511+
/**
512+
* Tests user removing EXDATE from master instance
513+
*/
514+
public function testParseEventForOrganizerRemoveExdate(): void {
515+
// construct calendar with recurring event that has EXDATE
516+
$originalCalendar = clone $this->vCalendar2a;
517+
$originalCalendar->VEVENT->add('EXDATE', ['20240715T080000', '20240722T080000'], ['TZID' => 'America/Toronto']);
518+
$originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]);
519+
// remove EXDATE to restore excluded occurrences
520+
$mutatedCalendar = clone $this->vCalendar2a;
521+
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
522+
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
523+
$mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]);
524+
// test iTip generation
525+
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]);
526+
$this->assertCount(1, $messages);
527+
$this->assertEquals('REQUEST', $messages[0]->method);
528+
$this->assertEquals(2, $messages[0]->sequence);
529+
$this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
530+
$this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
531+
// verify EXDATE is not present in the message
532+
$this->assertFalse(isset($messages[0]->message->VEVENT->EXDATE));
533+
}
534+
535+
/**
536+
* Tests user converting recurring event to non-scheduling
537+
*/
538+
public function testParseEventForOrganizerConvertRecurringToNonScheduling(): void {
539+
// construct calendar with recurring event
540+
$originalCalendar = clone $this->vCalendar2a;
541+
$originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]);
542+
// remove ORGANIZER and ATTENDEE properties to convert to non-scheduling
543+
$mutatedCalendar = clone $this->vCalendar2a;
544+
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
545+
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
546+
$mutatedCalendar->VEVENT->remove('ORGANIZER');
547+
$mutatedCalendar->VEVENT->remove('ATTENDEE');
548+
$mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]);
549+
// test iTip generation
550+
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]);
551+
$this->assertCount(1, $messages);
552+
$this->assertEquals('CANCEL', $messages[0]->method);
553+
$this->assertEquals(2, $messages[0]->sequence);
554+
$this->assertEquals($originalCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
555+
$this->assertEquals($originalCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
556+
}
557+
558+
/**
559+
* Tests SCHEDULE-FORCE-SEND parameter handling
560+
*/
561+
public function testParseEventForOrganizerScheduleForceSend(): void {
562+
// construct calendar with event
563+
$originalCalendar = clone $this->vCalendar1a;
564+
$originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]);
565+
// add SCHEDULE-FORCE-SEND parameter to ATTENDEE
566+
$mutatedCalendar = clone $this->vCalendar1a;
567+
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
568+
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
569+
$mutatedCalendar->VEVENT->ATTENDEE->add('SCHEDULE-FORCE-SEND', 'REQUEST');
570+
$mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]);
571+
// test iTip generation
572+
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$mutatedCalendar, $mutatedEventInfo, $originalEventInfo]);
573+
$this->assertCount(1, $messages);
574+
$this->assertEquals('REQUEST', $messages[0]->method);
575+
$this->assertEquals(2, $messages[0]->sequence);
576+
$this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
577+
$this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE->getValue(), $messages[0]->recipient);
578+
// verify SCHEDULE-FORCE-SEND is removed from the message (sanitized)
579+
$this->assertFalse(isset($messages[0]->message->VEVENT->ATTENDEE['SCHEDULE-FORCE-SEND']));
580+
}
581+
461582
}

0 commit comments

Comments
 (0)