diff options
author | Anna Larch <anna@nextcloud.com> | 2022-06-23 22:17:53 +0200 |
---|---|---|
committer | Anna Larch <anna@nextcloud.com> | 2022-08-22 22:10:12 +0200 |
commit | 4ca4b0279372cd6fe11f717ed697f905ecb1e4a2 (patch) | |
tree | 6a6c1fbddc1dd6d06e653cd59475a7d4e8be2374 | |
parent | 2576609aac3555da0926c27b879df610a8b0f43c (diff) | |
download | nextcloud-server-4ca4b0279372cd6fe11f717ed697f905ecb1e4a2.tar.gz nextcloud-server-4ca4b0279372cd6fe11f717ed697f905ecb1e4a2.zip |
Support iMIP invitations from Mail
Signed-off-by: Anna Larch <anna@nextcloud.com>
-rw-r--r-- | apps/dav/lib/CalDAV/CalDavBackend.php | 4 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/CalendarHome.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/CalendarImpl.php | 71 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/CalendarProvider.php | 1 | ||||
-rw-r--r-- | apps/dav/tests/unit/CalDAV/CalendarImplTest.php | 79 | ||||
-rw-r--r-- | lib/private/Calendar/Manager.php | 153 | ||||
-rw-r--r-- | lib/public/Calendar/ICreateFromString.php | 9 | ||||
-rw-r--r-- | lib/public/Calendar/IManager.php | 14 | ||||
-rw-r--r-- | tests/lib/Calendar/ManagerTest.php | 377 |
9 files changed, 698 insertions, 11 deletions
diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 2bbdc51f42e..42df838523d 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1864,6 +1864,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } + if(isset($options['uid'])) { + $outerQuery->andWhere($outerQuery->expr()->eq('uid', $outerQuery->createNamedParameter($options['uid']))); + } + if (!empty($options['types'])) { $or = $outerQuery->expr()->orX(); foreach ($options['types'] as $type) { diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index ceeba31800e..cd6ae1c2f7f 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -206,7 +206,6 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset); } - public function enableCachedSubscriptionsForThisRequest() { $this->returnCachedSubscriptions = true; } diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index 27a428a4075..a04e520c6bc 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -29,10 +29,19 @@ namespace OCA\DAV\CalDAV; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Calendar\Exceptions\CalendarException; use OCP\Calendar\ICreateFromString; use OCP\Constants; +use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\Conflict; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\Document; +use Sabre\VObject\ITip\Message; +use Sabre\VObject\Property\VCard\DateTime; +use Sabre\VObject\Reader; use function Sabre\Uri\split as uriSplit; class CalendarImpl implements ICreateFromString { @@ -46,13 +55,6 @@ class CalendarImpl implements ICreateFromString { /** @var array */ private $calendarInfo; - /** - * CalendarImpl constructor. - * - * @param Calendar $calendar - * @param array $calendarInfo - * @param CalDavBackend $backend - */ public function __construct(Calendar $calendar, array $calendarInfo, CalDavBackend $backend) { @@ -177,4 +179,59 @@ class CalendarImpl implements ICreateFromString { fclose($stream); } } + + /** + * @throws CalendarException + */ + public function handleIMipMessage(string $name, string $calendarData): void { + $server = new InvitationResponseServer(false); + + /** @var CustomPrincipalPlugin $plugin */ + $plugin = $server->server->getPlugin('auth'); + // we're working around the previous implementation + // that only allowed the public system principal to be used + // so set the custom principal here + $plugin->setCurrentPrincipal($this->calendar->getPrincipalURI()); + + if (empty($this->calendarInfo['uri'])) { + throw new CalendarException('Could not write to calendar as URI parameter is missing'); + } + // Force calendar change URI + /** @var Schedule\Plugin $schedulingPlugin */ + $schedulingPlugin = $server->server->getPlugin('caldav-schedule'); + // Let sabre handle the rest + $iTipMessage = new Message(); + /** @var VCalendar $vObject */ + $vObject = Reader::read($calendarData); + /** @var VEvent $vEvent */ + $vEvent = $vObject->{'VEVENT'}; + + if($vObject->{'METHOD'} === null) { + throw new CalendarException('No Method provided for scheduling data. Could not process message'); + } + + if(!isset($vEvent->{'ORGANIZER'}) || !isset($vEvent->{'ATTENDEE'})) { + throw new CalendarException('Could not process scheduling data, neccessary data missing from ICAL'); + } + $orgaizer = $vEvent->{'ORGANIZER'}->getValue(); + $attendee = $vEvent->{'ATTENDEE'}->getValue(); + + $iTipMessage->method = $vObject->{'METHOD'}->getValue(); + if($iTipMessage->method === 'REPLY') { + if ($server->isExternalAttendee($vEvent->{'ATTENDEE'}->getValue())) { + $iTipMessage->recipient = $orgaizer; + } else { + $iTipMessage->recipient = $attendee; + } + $iTipMessage->sender = $attendee; + } else if($iTipMessage->method === 'CANCEL') { + $iTipMessage->recipient = $attendee; + $iTipMessage->sender = $orgaizer; + } + $iTipMessage->uid = isset($vEvent->{'UID'}) ? $vEvent->{'UID'}->getValue() : ''; + $iTipMessage->component = 'VEVENT'; + $iTipMessage->sequence = isset($vEvent->{'SEQUENCE'}) ? (int)$vEvent->{'SEQUENCE'}->getValue() : 0; + $iTipMessage->message = $vObject; + $schedulingPlugin->scheduleLocalDelivery($iTipMessage); + } } diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index f29c601db2d..5779111add3 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace OCA\DAV\CalDAV; use OCP\Calendar\ICalendarProvider; +use OCP\Calendar\ICreateFromString; use OCP\IConfig; use OCP\IL10N; use Psr\Log\LoggerInterface; diff --git a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php index af8c056cac7..6842bdadb53 100644 --- a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php +++ b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php @@ -25,9 +25,13 @@ */ namespace OCA\DAV\Tests\unit\CalDAV; +use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCA\DAV\CalDAV\Schedule\Plugin; +use PHPUnit\Framework\MockObject\MockObject; class CalendarImplTest extends \Test\TestCase { @@ -51,6 +55,7 @@ class CalendarImplTest extends \Test\TestCase { 'id' => 'fancy_id_123', '{DAV:}displayname' => 'user readable name 123', '{http://apple.com/ns/ical/}calendar-color' => '#AABBCC', + 'uri' => '/this/is/a/uri' ]; $this->backend = $this->createMock(CalDavBackend::class); @@ -125,4 +130,78 @@ class CalendarImplTest extends \Test\TestCase { $this->assertEquals(31, $this->calendarImpl->getPermissions()); } + + public function testHandleImipMessage(): void { + $invitationResponseServer = $this->createConfiguredMock(InvitationResponseServer::class, [ + 'server' => $this->createConfiguredMock(CalDavBackend::class, [ + 'getPlugin' => [ + 'auth' => $this->createMock(CustomPrincipalPlugin::class), + 'schedule' => $this->createMock(Plugin::class) + ] + ]) + ]); + + $message = <<<EOF +BEGIN:VCALENDAR +PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;PARTSTAT=mailto:lewis@stardew-tent-living.com:ACCEPTED +ORGANIZER:mailto:pierre@generalstore.com +UID:aUniqueUid +SEQUENCE:2 +REQUEST-STATUS:2.0;Success +%sEND:VEVENT +END:VCALENDAR +EOF; + + /** @var CustomPrincipalPlugin|MockObject $authPlugin */ + $authPlugin = $invitationResponseServer->server->getPlugin('auth'); + $authPlugin->expects(self::once()) + ->method('setPrincipalUri') + ->with($this->calendar->getPrincipalURI()); + + /** @var Plugin|MockObject $schedulingPlugin */ + $schedulingPlugin = $invitationResponseServer->server->getPlugin('caldav-schedule'); + $schedulingPlugin->expects(self::once()) + ->method('setPathOfCalendarObjectChange') + ->with('fullcalendarname'); + } + + public function testHandleImipMessageNoCalendarUri(): void { + $invitationResponseServer = $this->createConfiguredMock(InvitationResponseServer::class, [ + 'server' => $this->createConfiguredMock(CalDavBackend::class, [ + 'getPlugin' => [ + 'auth' => $this->createMock(CustomPrincipalPlugin::class), + 'schedule' => $this->createMock(Plugin::class) + ] + ]) + ]); + + $message = <<<EOF +BEGIN:VCALENDAR +PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;PARTSTAT=mailto:lewis@stardew-tent-living.com:ACCEPTED +ORGANIZER:mailto:pierre@generalstore.com +UID:aUniqueUid +SEQUENCE:2 +REQUEST-STATUS:2.0;Success +%sEND:VEVENT +END:VCALENDAR +EOF; + + /** @var CustomPrincipalPlugin|MockObject $authPlugin */ + $authPlugin = $invitationResponseServer->server->getPlugin('auth'); + $authPlugin->expects(self::once()) + ->method('setPrincipalUri') + ->with($this->calendar->getPrincipalURI()); + + unset($this->calendarInfo['uri']); + $this->expectException('CalendarException'); + $this->calendarImpl->handleIMipMessage('filename.ics', $message); + } } diff --git a/lib/private/Calendar/Manager.php b/lib/private/Calendar/Manager.php index 16e142264aa..f0b8e9fd50d 100644 --- a/lib/private/Calendar/Manager.php +++ b/lib/private/Calendar/Manager.php @@ -27,12 +27,19 @@ declare(strict_types=1); namespace OC\Calendar; use OC\AppFramework\Bootstrap\Coordinator; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\Exceptions\CalendarException; use OCP\Calendar\ICalendar; use OCP\Calendar\ICalendarProvider; use OCP\Calendar\ICalendarQuery; +use OCP\Calendar\ICreateFromString; use OCP\Calendar\IManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\Property\VCard\DateTime; +use Sabre\VObject\Reader; use Throwable; use function array_map; use function array_merge; @@ -58,12 +65,17 @@ class Manager implements IManager { /** @var LoggerInterface */ private $logger; + private ITimeFactory $timeFactory; + + public function __construct(Coordinator $coordinator, ContainerInterface $container, - LoggerInterface $logger) { + LoggerInterface $logger, + ITimeFactory $timeFactory) { $this->coordinator = $coordinator; $this->container = $container; $this->logger = $logger; + $this->timeFactory = $timeFactory; } /** @@ -167,6 +179,11 @@ class Manager implements IManager { $this->calendarLoaders = []; } + /** + * @param string $principalUri + * @param array $calendarUris + * @return ICreateFromString[] + */ public function getCalendarsForPrincipal(string $principalUri, array $calendarUris = []): array { $context = $this->coordinator->getRegistrationContext(); if ($context === null) { @@ -198,7 +215,6 @@ class Manager implements IManager { ); $results = []; - /** @var ICalendar $calendar */ foreach ($calendars as $calendar) { $r = $calendar->search( $query->getSearchPattern() ?? '', @@ -219,4 +235,137 @@ class Manager implements IManager { public function newQuery(string $principalUri): ICalendarQuery { return new CalendarQuery($principalUri); } + + /** + * @throws \OCP\DB\Exception + */ + public function handleIMipReply(string $principalUri, string $sender, string $recipient, string $calendarData): bool { + /** @var VCalendar $vObject */ + $vObject = Reader::read($calendarData); + /** @var VEvent $vEvent */ + $vEvent = $vObject->{'VEVENT'}; + + // First, we check if the correct method is passed to us + if (strcasecmp('REPLY', $vObject->{'METHOD'}->getValue()) !== 0) { + $this->logger->warning('Wrong method provided for processing'); + return false; + } + + // check if mail recipient and organizer are one and the same + $organizer = substr($vEvent->{'ORGANIZER'}->getValue(), 7); + + if (strcasecmp($recipient, $organizer) !== 0) { + $this->logger->warning('Recipient and ORGANIZER must be identical'); + return false; + } + + //check if the event is in the future + /** @var DateTime $eventTime */ + $eventTime = $vEvent->{'DTSTART'}; + if ($eventTime->getDateTime()->getTimeStamp() < $this->timeFactory->getTime()) { // this might cause issues with recurrences + $this->logger->warning('Only events in the future are processed'); + return false; + } + + $calendars = $this->getCalendarsForPrincipal($principalUri); + if (empty($calendars)) { + $this->logger->warning('Could not find any calendars for principal ' . $principalUri); + return false; + } + + $found = null; + // if the attendee has been found in at least one calendar event with the UID of the iMIP event + // we process it. + // Benefit: no attendee lost + // Drawback: attendees that have been deleted will still be able to update their partstat + foreach ($calendars as $calendar) { + // We should not search in writable calendars + if ($calendar instanceof ICreateFromString) { + $o = $calendar->search($sender, ['ATTENDEE'], ['uid' => $vEvent->{'UID'}->getValue()]); + if (!empty($o)) { + $found = $calendar; + $name = $o[0]['uri']; + break; + } + } + } + + if (empty($found)) { + $this->logger->info('Event not found in any calendar for principal ' . $principalUri . 'and UID' . $vEvent->{'UID'}->getValue()); + return false; + } + + try { + $found->handleIMipMessage($name, $calendarData); // sabre will handle the scheduling behind the scenes + } catch (CalendarException $e) { + $this->logger->error('Could not update calendar for iMIP processing', ['exception' => $e]); + return false; + } + return true; + } + + /** + * @since 25.0.0 + * @throws \OCP\DB\Exception + */ + public function handleIMipCancel(string $principalUri, string $sender, ?string $replyTo, string $recipient, string $calendarData): bool { + $vObject = Reader::read($calendarData); + /** @var VEvent $vEvent */ + $vEvent = $vObject->{'VEVENT'}; + + // First, we check if the correct method is passed to us + if (strcasecmp('CANCEL', $vObject->{'METHOD'}->getValue()) !== 0) { + $this->logger->warning('Wrong method provided for processing'); + return false; + } + + $attendee = substr($vEvent->{'ATTENDEE'}->getValue(), 7); + if (strcasecmp($recipient, $attendee) !== 0) { + $this->logger->warning('Recipient must be an ATTENDEE of this event'); + return false; + } + + // Thirdly, we need to compare the email address the CANCEL is coming from (in Mail) + // or the Reply- To Address submitted with the CANCEL email + // to the email address in the ORGANIZER. + // We don't want to accept a CANCEL request from just anyone + $organizer = substr($vEvent->{'ORGANIZER'}->getValue(), 7); + if (strcasecmp($sender, $organizer) !== 0 && strcasecmp($replyTo, $organizer) !== 0) { + $this->logger->warning('Sender must be the ORGANIZER of this event'); + return false; + } + + $calendars = $this->getCalendarsForPrincipal($principalUri); + $found = null; + // if the attendee has been found in at least one calendar event with the UID of the iMIP event + // we process it. + // Benefit: no attendee lost + // Drawback: attendees that have been deleted will still be able to update their partstat + foreach ($calendars as $calendar) { + // We should not search in writable calendars + if ($calendar instanceof ICreateFromString) { + $o = $calendar->search($recipient, ['ATTENDEE'], ['uid' => $vEvent->{'UID'}->getValue()]); + if (!empty($o)) { + $found = $calendar; + $name = $o[0]['uri']; + break; + } + } + } + + if (empty($found)) { + $this->logger->info('Event not found in any calendar for principal ' . $principalUri . 'and UID' . $vEvent->{'UID'}->getValue()); + // this is a safe operation + // we can ignore events that have been cancelled but were not in the calendar anyway + return true; + } + + try { + $found->handleIMipMessage($name, $calendarData); // sabre will handle the scheduling behind the scenes + return true; + } catch (CalendarException $e) { + $this->logger->error('Could not update calendar for iMIP processing', ['exception' => $e]); + return false; + } + } } diff --git a/lib/public/Calendar/ICreateFromString.php b/lib/public/Calendar/ICreateFromString.php index 343405e8ab6..8c4bdd44041 100644 --- a/lib/public/Calendar/ICreateFromString.php +++ b/lib/public/Calendar/ICreateFromString.php @@ -1,4 +1,6 @@ <?php + +declare(strict_types=1); /** * @copyright 2021 Anna Larch <anna.larch@gmx.net> * @@ -38,4 +40,11 @@ interface ICreateFromString extends ICalendar { * @throws CalendarException */ public function createFromString(string $name, string $calendarData): void; + + /** + * @since 25.0.0 + * + * @throws CalendarException + */ + public function handleIMipMessage(string $name, string $calendarData): void; } diff --git a/lib/public/Calendar/IManager.php b/lib/public/Calendar/IManager.php index 7f0eec80910..dd65917d12b 100644 --- a/lib/public/Calendar/IManager.php +++ b/lib/public/Calendar/IManager.php @@ -156,4 +156,18 @@ interface IManager { * @since 23.0.0 */ public function newQuery(string $principalUri) : ICalendarQuery; + + /** + * Handle a iMip REPLY message + * + * @since 25.0.0 + */ + public function handleIMipReply(string $principalUri, string $sender, string $recipient, string $calendarData): bool; + + /** + * Handle a iMip CANCEL message + * + * @since 25.0.0 + */ + public function handleIMipCancel(string $principalUri, string $sender, ?string $replyTo, string $recipient, string $calendarData): bool; } diff --git a/tests/lib/Calendar/ManagerTest.php b/tests/lib/Calendar/ManagerTest.php index a4d9d45fdb2..8b99c21ae41 100644 --- a/tests/lib/Calendar/ManagerTest.php +++ b/tests/lib/Calendar/ManagerTest.php @@ -25,10 +25,14 @@ namespace Test\Calendar; use OC\AppFramework\Bootstrap\Coordinator; use OC\Calendar\Manager; +use OCP\AppFramework\Utility\ITimeFactory; use OCP\Calendar\ICalendar; +use OCP\Calendar\ICreateFromString; use PHPUnit\Framework\MockObject\MockObject; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Sabre\VObject\Document; +use Sabre\VObject\Reader; use Test\TestCase; class ManagerTest extends TestCase { @@ -45,17 +49,22 @@ class ManagerTest extends TestCase { /** @var Manager */ private $manager; + /** @var ITimeFactory|ITimeFactory&MockObject|MockObject */ + private $time; + protected function setUp(): void { parent::setUp(); $this->coordinator = $this->createMock(Coordinator::class); $this->container = $this->createMock(ContainerInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->time = $this->createMock(ITimeFactory::class); $this->manager = new Manager( $this->coordinator, $this->container, - $this->logger + $this->logger, + $this->time, ); } @@ -231,4 +240,370 @@ class ManagerTest extends TestCase { $isEnabled = $this->manager->isEnabled(); $this->assertTrue($isEnabled); } + + public function testHandleImipReplyWrongMethod(): void { + $principalUri = 'principals/user/linus'; + $sender = 'pierre@general-store.com'; + $recipient = 'linus@stardew-tent-living.com'; + $calendarData = $this->getVCalendarReply(); + $calendarData->METHOD = 'REQUEST'; + + $this->logger->expects(self::once()) + ->method('warning'); + $this->time->expects(self::never()) + ->method('getTimestamp'); + + $result = $this->manager->handleIMipReply($principalUri, $sender, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipReplyOrganizerNotRecipient(): void { + $principalUri = 'principals/user/linus'; + $recipient = 'pierre@general-store.com'; + $sender = 'linus@stardew-tent-living.com'; + $calendarData = $this->getVCalendarReply(); + + $this->logger->expects(self::once()) + ->method('warning'); + $this->time->expects(self::never()) + ->method('getTimestamp'); + + $result = $this->manager->handleIMipReply($principalUri, $sender, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipReplyDateInThePast(): void { + $principalUri = 'principals/user/linus'; + $sender = 'pierre@general-store.com'; + $recipient = 'linus@stardew-tent-living.com'; + $calendarData = $this->getVCalendarReply(); + $calendarData->VEVENT->DTSTART = new \DateTime('2013-04-07'); // set to in the past + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(time()); + + $this->logger->expects(self::once()) + ->method('warning'); + + $result = $this->manager->handleIMipReply($principalUri, $sender, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipReplyNoCalendars(): void { + /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->setMethods([ + 'getCalendarsForPrincipal' + ]); + $principalUri = 'principals/user/linus'; + $sender = 'pierre@general-store.com'; + $recipient = 'linus@stardew-tent-living.com'; + $calendarData = $this->getVCalendarReply(); + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(202208219); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([]); + $this->logger->expects(self::once()) + ->method('warning'); + + $result = $manager->handleIMipReply($principalUri, $sender, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipReplyEventNotFound(): void { + /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->setMethods([ + 'getCalendarsForPrincipal' + ]); + $calendar = $this->createMock(ICreateFromString::class); + $principalUri = 'principals/user/linus'; + $sender = 'pierre@general-store.com'; + $recipient = 'linus@stardew-tent-living.com'; + $calendarData = $this->getVCalendarReply(); + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(202208219); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$calendar]); + $calendar->expects(self::once()) + ->method('search') + ->willReturn([]); + $this->logger->expects(self::once()) + ->method('info'); + $calendar->expects(self::never()) + ->method('handleIMipMessage'); + + $result = $manager->handleIMipReply($principalUri, $sender, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipReply(): void { + /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->setMethods([ + 'getCalendarsForPrincipal' + ]); + $calendar = $this->createMock(ICreateFromString::class); + $principalUri = 'principals/user/linus'; + $sender = 'pierre@general-store.com'; + $recipient = 'linus@stardew-tent-living.com'; + $calendarData = $this->getVCalendarReply(); + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(202208219); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$calendar]); + $calendar->expects(self::once()) + ->method('search') + ->willReturn([['uri' => 'testname.ics']]); + $calendar->expects(self::once()) + ->method('handleIMipMessage') + ->with('testname.ics', $calendarData->serialize()); + + $result = $manager->handleIMipReply($principalUri, $sender, $recipient, $calendarData->serialize()); + $this->assertTrue($result); + } + + public function testHandleImipCancelWrongMethod(): void { + $principalUri = 'principals/user/pierre'; + $sender = 'linus@stardew-tent-living.com'; + $recipient = 'pierre@general-store.com'; + $replyTo = null; + $calendarData = $this->getVCalendarCancel(); + $calendarData->VEVENT->METHOD = 'REQUEST'; + + $this->logger->expects(self::once()) + ->method('warning'); + $this->time->expects(self::never()) + ->method('getTimestamp'); + + $result = $this->manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipCancelAttendeeNotRecipient(): void { + $principalUri = '/user/admin'; + $sender = 'linus@stardew-tent-living.com'; + $recipient = 'leah@general-store.com'; + $replyTo = null; + $calendarData = $this->getVCalendarCancel(); + $calendarData->VEVENT->METHOD = 'CANCEL'; + + $this->logger->expects(self::once()) + ->method('warning'); + $this->time->expects(self::never()) + ->method('getTimestamp'); + + $result = $this->manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipCancelDateInThePast(): void { + $principalUri = 'principals/user/pierre'; + $sender = 'linus@stardew-tent-living.com'; + $recipient = 'pierre@general-store.com'; + $replyTo = null; + $calendarData = $this->getVCalendarCancel(); + $calendarData->VEVENT->DTSTART = new \DateTime('2013-04-07'); // set to in the past + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(time()); + $this->logger->expects(self::once()) + ->method('warning'); + + $result = $this->manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipCancelNoCalendars(): void { + /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->setMethods([ + 'getCalendarsForPrincipal' + ]); + $principalUri = 'principals/user/pierre'; + $sender = 'linus@stardew-tent-living.com'; + $recipient = 'pierre@general-store.com'; + $replyTo = null; + $calendarData = $this->getVCalendarCancel(); + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(202208219); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with($principalUri) + ->willReturn([]); + $this->logger->expects(self::once()) + ->method('warning'); + + $result = $this->manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + $this->assertTrue($result); + } + + public function testHandleImipCancelOrganiserInReplyTo(): void { + /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->setMethods([ + 'getCalendarsForPrincipal' + ]); + $principalUri = 'principals/user/pierre'; + $sender = 'clint@stardew-blacksmiths.com'; + $recipient = 'pierre@general-store.com'; + $replyTo = 'linus@stardew-tent-living.com'; + $calendar = $this->createMock(ICreateFromString::class); + $calendarData = $this->getVCalendarCancel(); + $calendarData->VEVENT->METHOD = 'CANCEL'; + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(202208219); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with($principalUri) + ->willReturn([$calendar]); + $calendar->expects(self::once()) + ->method('search') + ->willReturn([['uri' => 'testname.ics']]); + $calendar->expects(self::once()) + ->method('handleIMipMessage') + ->with('testname.ics', $calendarData->serialize()); + $result = $this->manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipCancel(): void { + /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->setMethods([ + 'getCalendarsForPrincipal' + ]); + $principalUri = 'principals/user/pierre'; + $sender = 'linus@stardew-tent-living.com'; + $recipient = 'pierre@general-store.com'; + $replyTo = null; + $calendar = $this->createMock(ICreateFromString::class); + $calendarData = $this->getVCalendarCancel(); + $calendarData->VEVENT->METHOD = 'CANCEL'; + + $this->time->expects(self::once()) + ->method('getTimestamp') + ->willReturn(202208219); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with($principalUri) + ->willReturn([$calendar]); + $calendar->expects(self::once()) + ->method('search') + ->willReturn([['uri' => 'testname.ics']]); + $calendar->expects(self::once()) + ->method('handleIMipMessage') + ->with('testname.ics', $calendarData->serialize()); + $result = $this->manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + $this->assertFalse($result); + } + + private function getVCalendarReply(): Document { + $data = <<<EOF +BEGIN:VCALENDAR +PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:REPLY +BEGIN:VEVENT +DTSTART;VALUE=DATE:20210820 +DTEND;VALUE=DATE:20220821 +DTSTAMP:20210812T100040Z +ORGANIZER;CN=admin:mailto:linus@stardew-tent-living.com +UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=pierr + e@general-store.com;X-NUM-GUESTS=0:mailto:pierre@general-store.com +CREATED:20220812T100021Z +DESCRIPTION: +LAST-MODIFIED:20220812T100040Z +LOCATION: +SEQUENCE:3 +STATUS:CONFIRMED +SUMMARY:berry basket +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +EOF; + return Reader::read($data); + } + + private function getVCalendarCancel(): Document { + $data = <<<EOF +BEGIN:VCALENDAR +PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:CANCEL +BEGIN:VEVENT +DTSTART;VALUE=DATE:20210820 +DTEND;VALUE=DATE:20220821 +DTSTAMP:20210812T100040Z +ORGANIZER;CN=admin:mailto:linus@stardew-tent-living.com +UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff +ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=pierr + e@general-store.com;X-NUM-GUESTS=0:mailto:pierre@general-store.com +CREATED:20220812T100021Z +DESCRIPTION: +LAST-MODIFIED:20220812T100040Z +LOCATION: +SEQUENCE:3 +STATUS:CANCELLED +SUMMARY:berry basket +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR +EOF; + return Reader::read($data); + } } |