Merge pull request #59988 from nextcloud/feat/party-crasher

caldav party crasher
This commit is contained in:
Daniel 2026-05-19 23:39:46 +02:00 committed by GitHub
commit 69d0b7e2e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 568 additions and 11 deletions

View file

@ -19,6 +19,7 @@ use OCA\DAV\CalDAV\Reminder\NotificationProvider\EmailProvider;
use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider;
use OCA\DAV\CalDAV\Reminder\NotificationProviderManager;
use OCA\DAV\CalDAV\Reminder\Notifier as NotifierCalDAV;
use OCA\DAV\CalDAV\TipBroker;
use OCA\DAV\Capabilities;
use OCA\DAV\CardDAV\ContactsManager;
use OCA\DAV\CardDAV\Notification\Notifier as NotifierCardDAV;
@ -108,6 +109,7 @@ use OCP\User\Events\UserIdAssignedEvent;
use OCP\User\Events\UserIdUnassignedEvent;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Sabre\VObject;
use Throwable;
use function is_null;
@ -238,6 +240,8 @@ class Application extends App implements IBootstrap {
#[\Override]
public function boot(IBootContext $context): void {
VObject\Component\VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class;
// Load all dav apps
$context->getServerContainer()->get(IAppManager::class)->loadApps(['dav']);

View file

@ -11,10 +11,18 @@ namespace OCA\DAV\CalDAV;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\ITip\Broker;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Parameter;
use Sabre\VObject\Property;
use Sabre\VObject\Property\Boolean;
use Sabre\VObject\Property\ICalendar\CalAddress;
use Sabre\VObject\Property\ICalendar\DateTime;
use Sabre\VObject\Recur\EventIterator;
class TipBroker extends Broker {
public const INVITATION_FORWARDING_PROPERTY = 'X-NC-INVITATION-FORWARDING';
public $significantChangeProperties = [
'DTSTART',
@ -79,6 +87,181 @@ class TipBroker extends Broker {
return $existingObject;
}
#[\Override]
protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null) {
// A reply can only be processed based on an existing object.
// If the object is not available, the reply is ignored.
if ($existingObject === null) {
return null;
}
$instances = [];
$requestStatus = '2.0';
/** @var list<VEvent> $vevents */
$vevents = $itipMessage->message->select('VEVENT');
// Finding all the instances the attendee replied to.
foreach ($vevents as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = $this->getRecurrenceKey($vevent);
$attendee = $this->getFirstAttendee($vevent);
if ($attendee === null) {
continue;
}
$partstat = $attendee->offsetGet('PARTSTAT');
if (!$partstat instanceof Parameter) {
continue;
}
$instances[$recurId] = $partstat->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) {
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}
// Now we need to loop through the original organizer event, to find
// all the instances where we have a reply for.
/** @var VEvent|null $masterObject */
$masterObject = $existingObject->getBaseComponent('VEVENT');
$masterAllowInvitationForwarding = $masterObject === null || $this->allowInvitationForwarding($masterObject);
/** @var list<VEvent> $vevents */
$vevents = $existingObject->select('VEVENT');
foreach ($vevents as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
$recurId = $this->getRecurrenceKey($vevent);
if (isset($instances[$recurId])) {
$allowInvitationForwarding = $this->allowInvitationForwarding($vevent);
$attendeeFound = false;
if (isset($vevent->ATTENDEE)) {
foreach ($vevent->ATTENDEE as $attendee) {
if ($attendee->getValue() === $itipMessage->sender) {
$attendeeFound = true;
$attendee['PARTSTAT'] = $instances[$recurId];
$attendee['SCHEDULE-STATUS'] = $requestStatus;
// Un-setting the RSVP status, because we now know
// that the attendee already replied.
unset($attendee['RSVP']);
break;
}
}
}
if (!$attendeeFound && $allowInvitationForwarding) {
// Adding a new attendee. The iTip documentation calls this
// a party crasher.
$parameters = [
'PARTSTAT' => $instances[$recurId],
];
if ($itipMessage->senderName) {
$parameters['CN'] = $itipMessage->senderName;
}
$vevent->add('ATTENDEE', $itipMessage->sender, $parameters);
}
unset($instances[$recurId]);
}
}
if ($masterObject === null) {
// No master object, we can't add new instances.
return null;
}
// If we got replies to instances that did not exist in the
// original list, it means that new exceptions must be created.
foreach ($instances as $recurId => $partstat) {
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
$found = false;
$iterations = 1000;
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();
// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);
// Invalid recurrence id. Skipping this object.
if (!$found) {
continue;
}
$newObject->remove('RRULE');
$newObject->remove('EXDATE');
$newObject->remove('RDATE');
$attendeeFound = false;
if (isset($newObject->ATTENDEE)) {
foreach ($newObject->ATTENDEE as $attendee) {
if ($attendee->getValue() === $itipMessage->sender) {
$attendeeFound = true;
$attendee['PARTSTAT'] = $partstat;
$attendee['SCHEDULE-STATUS'] = $requestStatus;
unset($attendee['RSVP']);
break;
}
}
}
if (!$attendeeFound && !$masterAllowInvitationForwarding) {
continue;
}
if (!$attendeeFound) {
// Adding a new attendee
$parameters = [
'PARTSTAT' => $partstat,
];
if ($itipMessage->senderName) {
$parameters['CN'] = $itipMessage->senderName;
}
$newObject->add('ATTENDEE', $itipMessage->sender, $parameters);
}
$existingObject->add($newObject);
}
return $existingObject;
}
/**
* @return int|'master'
*/
protected function getRecurrenceKey(VEvent $vevent): int|string {
/** @var list<Property> $recurrenceIds */
$recurrenceIds = $vevent->select('RECURRENCE-ID');
foreach ($recurrenceIds as $recurrenceId) {
if ($recurrenceId instanceof DateTime) {
return $recurrenceId->getDateTime()->getTimestamp();
}
}
return 'master';
}
protected function getFirstAttendee(VEvent $vevent): ?CalAddress {
/** @var list<Property> $attendees */
$attendees = $vevent->select('ATTENDEE');
foreach ($attendees as $attendee) {
if ($attendee instanceof CalAddress) {
return $attendee;
}
}
return null;
}
protected function allowInvitationForwarding(VEvent $vevent): bool {
$properties = $vevent->select(self::INVITATION_FORWARDING_PROPERTY);
foreach ($properties as $property) {
if ($property instanceof Boolean) {
return $property->getValue() === 'TRUE';
}
}
return true;
}
/**
* This method is used in cases where an event got updated, and we
* potentially need to send emails to attendees to let them know of updates

View file

@ -8,7 +8,9 @@ declare(strict_types=1);
namespace OCA\DAV\Tests\unit\CalDAV;
use OCA\DAV\CalDAV\TipBroker;
use Sabre\VObject;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\ITip\Message;
use Test\TestCase;
class TipBrokerTest extends TestCase {
@ -21,6 +23,8 @@ class TipBrokerTest extends TestCase {
protected function setUp(): void {
parent::setUp();
VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class;
$this->broker = new TipBroker();
$this->templateEventInfo = [
@ -41,8 +45,8 @@ class TipBrokerTest extends TestCase {
$vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']);
$vEvent->add('SUMMARY', 'Test Event');
$vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']);
$vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [
$vEvent->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']);
$vEvent->add('ATTENDEE', 'mailto:attendee1@example.org', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -65,8 +69,8 @@ class TipBrokerTest extends TestCase {
$vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']);
$vEvent->add('RRULE', 'FREQ=WEEKLY;COUNT=12;BYDAY=MO');
$vEvent->add('SUMMARY', 'Test Event');
$vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']);
$vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [
$vEvent->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']);
$vEvent->add('ATTENDEE', 'mailto:attendee1@example.org', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -75,6 +79,12 @@ class TipBrokerTest extends TestCase {
]);
}
protected function tearDown(): void {
unset(VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY]);
parent::tearDown();
}
/**
* Tests user creating a new singleton or recurring event
*/
@ -159,7 +169,7 @@ class TipBrokerTest extends TestCase {
$mutatedCalendar = clone $this->vCalendar1a;
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
$mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [
$mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@example.org', [
'CN' => 'Attendee Two',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -185,7 +195,7 @@ class TipBrokerTest extends TestCase {
public function testParseEventForOrganizerRemoveAttendee(): void {
// construct calendar and generate event info for modified event with two attendees
$originalCalendar = clone $this->vCalendar1a;
$originalCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [
$originalCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@example.org', [
'CN' => 'Attendee Two',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -197,7 +207,7 @@ class TipBrokerTest extends TestCase {
$mutatedCalendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
$mutatedCalendar->VEVENT->SEQUENCE->setValue(2);
$mutatedCalendar->VEVENT->remove('ATTENDEE');
$mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@testing.com', [
$mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@example.org', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -214,7 +224,7 @@ class TipBrokerTest extends TestCase {
$this->assertEquals($mutatedCalendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
$this->assertEquals('CANCEL', $messages[1]->method);
$this->assertEquals($mutatedCalendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender);
$this->assertEquals('mailto:attendee2@testing.com', $messages[1]->recipient);
$this->assertEquals('mailto:attendee2@example.org', $messages[1]->recipient);
}
@ -377,7 +387,7 @@ class TipBrokerTest extends TestCase {
$originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]);
$mutatedInstance = clone $originalInstance;
$mutatedInstance->SEQUENCE->setValue(2);
$mutatedInstance->add('ATTENDEE', 'mailto:attendee2@testing.com', [
$mutatedInstance->add('ATTENDEE', 'mailto:attendee2@example.org', [
'CN' => 'Attendee Two',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -417,7 +427,7 @@ class TipBrokerTest extends TestCase {
$originalInstance->SEQUENCE->setValue(1);
$originalInstance->DTSTART->setValue('20240717T080000');
$originalInstance->DTEND->setValue('20240717T090000');
$originalInstance->add('ATTENDEE', 'mailto:attendee2@testing.com', [
$originalInstance->add('ATTENDEE', 'mailto:attendee2@example.org', [
'CN' => 'Attendee Two',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -429,7 +439,7 @@ class TipBrokerTest extends TestCase {
$mutatedInstance = clone $originalInstance;
$mutatedInstance->SEQUENCE->setValue(2);
$mutatedInstance->remove('ATTENDEE');
$mutatedInstance->add('ATTENDEE', 'mailto:attendee1@testing.com', [
$mutatedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
@ -579,4 +589,364 @@ class TipBrokerTest extends TestCase {
$this->assertFalse(isset($messages[0]->message->VEVENT->ATTENDEE['SCHEDULE-FORCE-SEND']));
}
public function testProcessMessageReplyDisallowsInvitationForwarding(): void {
$existingCalendar = clone $this->vCalendar1a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue());
}
public function testProcessMessageReplyUpdatesExistingAttendeeWhenInvitationForwardingDisabled(): void {
$existingCalendar = clone $this->vCalendar1a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee1@example.org';
$reply->senderName = 'Attendee One';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$replyEvent->add('REQUEST-STATUS', '2.0;Success');
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue());
$this->assertEquals('2.0', $result->VEVENT->ATTENDEE[0]['SCHEDULE-STATUS']->getValue());
$this->assertFalse(isset($result->VEVENT->ATTENDEE[0]['RSVP']));
}
public function testProcessMessageReplyAllowsInvitationForwarding(): void {
$existingCalendar = clone $this->vCalendar1a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee2@example.org', $result->VEVENT->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT->ATTENDEE[1]['CN']->getValue());
}
public function testProcessMessageReplyAllowsInvitationForwardingByDefault(): void {
$existingCalendar = clone $this->vCalendar1a;
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee2@example.org', $result->VEVENT->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT->ATTENDEE[1]['CN']->getValue());
}
public function testProcessMessageReplyIgnoresReplyWithoutAttendee(): void {
$existingCalendar = clone $this->vCalendar1a;
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee1@example.org';
$reply->senderName = 'Attendee One';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('NEEDS-ACTION', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue());
$this->assertTrue(isset($result->VEVENT->ATTENDEE[0]['RSVP']));
}
public function testProcessMessageReplyIgnoresReplyWithoutPartstat(): void {
$existingCalendar = clone $this->vCalendar1a;
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee1@example.org';
$reply->senderName = 'Attendee One';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('ATTENDEE', $reply->sender, []);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('NEEDS-ACTION', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue());
$this->assertTrue(isset($result->VEVENT->ATTENDEE[0]['RSVP']));
}
public function testProcessMessageReplyDisallowsInvitationForwardingForGeneratedRecurringInstance(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(1, $result->VEVENT);
$this->assertCount(1, $result->VEVENT->ATTENDEE);
$this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue());
}
public function testProcessMessageReplyUpdatesExistingAttendeeForGeneratedRecurringInstanceWhenInvitationForwardingDisabled(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee1@example.org';
$reply->senderName = 'Attendee One';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$replyEvent->add('REQUEST-STATUS', '2.0;Success');
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue());
$this->assertCount(1, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee1@example.org', $result->VEVENT[1]->ATTENDEE[0]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[0]['PARTSTAT']->getValue());
$this->assertEquals('2.0', $result->VEVENT[1]->ATTENDEE[0]['SCHEDULE-STATUS']->getValue());
$this->assertFalse(isset($result->VEVENT[1]->ATTENDEE[0]['RSVP']));
}
public function testProcessMessageReplyAllowsInvitationForwardingForDetachedRecurringExceptionWhenMasterDisallows(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
/** @var \Sabre\VObject\Component\VEvent $detachedInstance */
$detachedInstance = $existingCalendar->add('VEVENT', []);
$detachedInstance->add('UID', $existingCalendar->VEVENT->UID->getValue());
$detachedInstance->add('DTSTAMP', '20240701T000000Z');
$detachedInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$detachedInstance->add('DTSTART', '20240715T080000', ['TZID' => 'America/Toronto']);
$detachedInstance->add('DTEND', '20240715T090000', ['TZID' => 'America/Toronto']);
$detachedInstance->add('SUMMARY', 'Detached Test Event');
$detachedInstance->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']);
$detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE');
$detachedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT',
'RSVP' => 'TRUE',
]);
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertCount(2, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee2@example.org', $result->VEVENT[1]->ATTENDEE[1]->getValue());
}
public function testProcessMessageReplyDisallowsInvitationForwardingForDetachedRecurringExceptionWhenMasterAllows(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
/** @var \Sabre\VObject\Component\VEvent $detachedInstance */
$detachedInstance = $existingCalendar->add('VEVENT', []);
$detachedInstance->add('UID', $existingCalendar->VEVENT->UID->getValue());
$detachedInstance->add('DTSTAMP', '20240701T000000Z');
$detachedInstance->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$detachedInstance->add('DTSTART', '20240715T080000', ['TZID' => 'America/Toronto']);
$detachedInstance->add('DTEND', '20240715T090000', ['TZID' => 'America/Toronto']);
$detachedInstance->add('SUMMARY', 'Detached Test Event');
$detachedInstance->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']);
$detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE');
$detachedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT',
'RSVP' => 'TRUE',
]);
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertCount(1, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee1@example.org', $result->VEVENT[1]->ATTENDEE[0]->getValue());
}
public function testProcessMessageReplyAllowsInvitationForwardingForGeneratedRecurringInstance(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE');
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue());
$this->assertFalse(isset($result->VEVENT[1]->RRULE));
$this->assertCount(2, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee2@example.org', $result->VEVENT[1]->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue());
}
public function testProcessMessageReplyAllowsInvitationForwardingByDefaultForGeneratedRecurringInstance(): void {
$existingCalendar = clone $this->vCalendar2a;
$existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org');
$reply = new Message();
$reply->uid = $existingCalendar->VEVENT->UID->getValue();
$reply->component = 'VEVENT';
$reply->sender = 'mailto:attendee2@example.org';
$reply->senderName = 'Attendee Two';
$reply->sequence = 1;
$reply->message = new VCalendar();
/** @var \Sabre\VObject\Component\VEvent $replyEvent */
$replyEvent = $reply->message->add('VEVENT', []);
$replyEvent->add('UID', $reply->uid);
$replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']);
$replyEvent->add('ATTENDEE', $reply->sender, [
'PARTSTAT' => 'ACCEPTED',
]);
$result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]);
$this->assertSame($existingCalendar, $result);
$this->assertCount(2, $result->VEVENT);
$this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue());
$this->assertFalse(isset($result->VEVENT[1]->RRULE));
$this->assertCount(2, $result->VEVENT[1]->ATTENDEE);
$this->assertEquals('mailto:attendee2@example.org', $result->VEVENT[1]->ATTENDEE[1]->getValue());
$this->assertEquals('ACCEPTED', $result->VEVENT[1]->ATTENDEE[1]['PARTSTAT']->getValue());
$this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue());
}
}