From 72702d71bc044b70a0963eb53c7300f76f56db01 Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Wed, 10 Jun 2026 15:22:33 +0000 Subject: [PATCH] fix(dav): default reminder on received invitations + preserve recipient VALARMs Signed-off-by: Nico Donath Co-Authored-By: Lucas Ferreira da Silva Assisted-by: ClaudeCode:claude-opus-4-7 --- apps/dav/appinfo/v1/caldav.php | 2 +- apps/dav/lib/CalDAV/EmbeddedCalDavServer.php | 2 +- .../InvitationResponseServer.php | 2 +- apps/dav/lib/CalDAV/Schedule/Plugin.php | 242 ++++++++++++++- apps/dav/lib/Server.php | 2 +- .../tests/unit/CalDAV/Schedule/PluginTest.php | 292 +++++++++++++++++- 6 files changed, 530 insertions(+), 12 deletions(-) diff --git a/apps/dav/appinfo/v1/caldav.php b/apps/dav/appinfo/v1/caldav.php index d1c1381be0a6e..0c4e33e706ba6 100644 --- a/apps/dav/appinfo/v1/caldav.php +++ b/apps/dav/appinfo/v1/caldav.php @@ -118,7 +118,7 @@ $server->addPlugin(new \Sabre\DAV\Sync\Plugin()); $server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); -$server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class))); +$server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class), $calDavBackend, Server::get(\OCP\Config\IUserConfig::class), Server::get(\OCP\IAppConfig::class))); if ($sendInvitations) { $server->addPlugin(Server::get(IMipPlugin::class)); diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php index 6037fa3194e33..3a9dda6ddb9ec 100644 --- a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php +++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php @@ -86,7 +86,7 @@ public function __construct(bool $public = true) { // calendar plugins $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class))); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class), Server::get(\OCA\DAV\CalDAV\CalDavBackend::class), Server::get(\OCP\Config\IUserConfig::class), Server::get(\OCP\IAppConfig::class))); $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest())); diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php index de7815c68f2d7..eece1908403f5 100644 --- a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -83,7 +83,7 @@ public function __construct(bool $public = true) { // calendar plugins $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class))); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class), Server::get(\OCA\DAV\CalDAV\CalDavBackend::class), Server::get(\OCP\Config\IUserConfig::class), Server::get(\OCP\IAppConfig::class))); $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest())); diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index 3cc0d190d3b93..8e46ac7492a23 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -15,6 +15,8 @@ use OCA\DAV\CalDAV\DefaultCalendarValidator; use OCA\DAV\CalDAV\Federation\FederatedCalendar; use OCA\DAV\CalDAV\TipBroker; +use OCP\Config\IUserConfig; +use OCP\IAppConfig; use OCP\IConfig; use Psr\Log\LoggerInterface; use Sabre\CalDAV\ICalendar; @@ -53,13 +55,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type'; public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL'; - /** - * @param IConfig $config - */ public function __construct( private IConfig $config, private LoggerInterface $logger, private DefaultCalendarValidator $defaultCalendarValidator, + private CalDavBackend $caldavBackend, + private IUserConfig $userConfig, + private IAppConfig $appConfig, ) { } @@ -257,12 +259,16 @@ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void { /** @var VEvent|null $vevent */ $vevent = $iTipMessage->message->VEVENT ?? null; - // Strip VALARMs from incoming VEVENT - if ($vevent && isset($vevent->VALARM)) { - $vevent->remove('VALARM'); + // A remote organizer must not drive alerts on the recipient's devices. + foreach ($iTipMessage->message->VEVENT ?? [] as $component) { + $component->remove('VALARM'); } - parent::scheduleLocalDelivery($iTipMessage); + if ($vevent && strcasecmp($iTipMessage->method, 'REQUEST') === 0) { + $this->applyRecipientReminderPolicy($iTipMessage); + } + + $this->delegateToSabre($iTipMessage); // We only care when the message was successfully delivered locally // Log all possible codes returned from the parent method that mean something went wrong // 3.7, 3.8, 5.0, 5.2 @@ -777,4 +783,226 @@ private function handleSameOrganizerException( } } } + + /** + * Thin testable seam around the parent's `scheduleLocalDelivery`. Tests subclass and + * override this to skip the Sabre delivery while still exercising our hook code. + * + * @param ITip\Message $iTipMessage + */ + protected function delegateToSabre(ITip\Message $iTipMessage): void { + parent::scheduleLocalDelivery($iTipMessage); + } + + /** + * For an incoming REQUEST, preserve the recipient's existing VALARMs or inject their default reminder. + * + * @param ITip\Message $iTipMessage + */ + private function applyRecipientReminderPolicy(ITip\Message $iTipMessage): void { + try { + /** @var \Sabre\DAVACL\Plugin|null $aclPlugin */ + $aclPlugin = $this->server->getPlugin('acl'); + if (!$aclPlugin) { + return; + } + $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient); + if (!$principalUri) { + return; + } + + $calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri); + if ($calendarUserType !== null + && (strcasecmp($calendarUserType, 'ROOM') === 0 || strcasecmp($calendarUserType, 'RESOURCE') === 0)) { + return; + } + + // Skip when the recipient is the organizer (self-invite). + /** @var VEvent|null $incomingVEvent */ + $incomingVEvent = $iTipMessage->message->VEVENT ?? null; + if ($incomingVEvent && isset($incomingVEvent->ORGANIZER)) { + $organizerAddresses = $this->getAddressesForPrincipal($principalUri); + /** @var Property&Property\ICalendar\CalAddress $organizer */ + $organizer = $incomingVEvent->ORGANIZER; + if (in_array($organizer->getNormalizedValue(), $organizerAddresses, true)) { + return; + } + } + + $userId = $this->principalUriToUserId($principalUri); + if ($userId === null) { + return; + } + + $existingVAlarms = $this->collectExistingValarmsForRecipient($principalUri, $iTipMessage->uid); + if ($existingVAlarms !== null) { + // An empty set means the recipient deleted them on purpose; never re-inject a default. + foreach ($iTipMessage->message->VEVENT as $vevent) { + $key = $this->veventRecurrenceKey($vevent); + foreach ($existingVAlarms[$key] ?? [] as $valarm) { + $vevent->add(clone $valarm); + } + } + return; + } + + $toggle = $this->userConfig->getValueString($userId, 'calendar', 'applyDefaultReminderToInvitations', 'yes'); + if ($toggle !== 'yes') { + return; + } + $offset = $this->resolveDefaultReminderOffset($userId, $incomingVEvent); + if ($offset !== 'none') { + $this->injectDefaultReminder($iTipMessage, $offset); + } + } catch (\Throwable $e) { + // Best-effort: never let reminder handling break delivery. + $this->logger->debug('Failed to apply recipient reminder policy on invitation', ['exception' => $e]); + } + } + + /** + * Collect every VALARM from the recipient's existing local copy of the given UID, keyed by + * RECURRENCE-ID (empty string for the master VEVENT). Returns null when the recipient has no + * local copy of this UID yet, which is the signal for the first-receipt path. + * + * @param string $principalUri + * @param string $uid + * @return array>|null + */ + private function collectExistingValarmsForRecipient(string $principalUri, string $uid): ?array { + $objectPath = $this->caldavBackend->getCalendarObjectByUID($principalUri, $uid); + if ($objectPath === null) { + return null; + } + [$calendarUri, $objectUri] = explode('/', $objectPath, 2); + $calendar = $this->caldavBackend->getCalendarByUri($principalUri, $calendarUri); + if (!$calendar) { + return []; + } + $objectData = $this->caldavBackend->getCalendarObject($calendar['id'], $objectUri); + if (empty($objectData['calendardata'])) { + return []; + } + $vCal = Reader::read($objectData['calendardata']); + $result = []; + foreach ($vCal->VEVENT ?? [] as $vevent) { + if (!isset($vevent->VALARM)) { + continue; + } + $key = $this->veventRecurrenceKey($vevent); + $result[$key] = []; + foreach ($vevent->VALARM as $valarm) { + $result[$key][] = $valarm; + } + } + return $result; + } + + /** + * Stable key per VEVENT component: '' for the master, otherwise the RECURRENCE-ID as an + * absolute timestamp so TZID-local and UTC spellings of the same instance still match. + * + * @param VEvent $vevent + * @return string + */ + private function veventRecurrenceKey(VEvent $vevent): string { + /** @var \Sabre\VObject\Property\ICalendar\DateTime|null $rid */ + $rid = $vevent->{'RECURRENCE-ID'} ?? null; + if ($rid === null) { + return ''; + } + try { + return (string)$rid->getDateTime()->getTimestamp(); + } catch (\Throwable $e) { + return (string)$rid; + } + } + + /** + * Resolves the per-user default reminder offset for a first-receipt invitation, + * matching the calendar app's frontend fallback chain. + * + * @param string $userId + * @param VEvent|null $vevent + * @return string 'none' to skip, otherwise a signed integer-as-string (seconds before start). + */ + private function resolveDefaultReminderOffset(string $userId, ?VEvent $vevent): string { + $isAllDay = $vevent !== null && isset($vevent->DTSTART) && !$vevent->DTSTART->hasTime(); + $typedKey = $isAllDay ? 'defaultReminderFullDay' : 'defaultReminderPartDay'; + $typed = $this->userConfig->getValueString($userId, 'calendar', $typedKey, 'none'); + if (filter_var($typed, FILTER_VALIDATE_INT) !== false) { + return $typed; + } + $legacy = $this->userConfig->getValueString($userId, 'calendar', 'defaultReminder', 'none'); + if (filter_var($legacy, FILTER_VALIDATE_INT) !== false) { + return $legacy; + } + $admin = $this->appConfig->getValueString('calendar', 'defaultReminder', 'none'); + if (filter_var($admin, FILTER_VALIDATE_INT) !== false) { + return $admin; + } + return 'none'; + } + + /** + * Adds a DISPLAY VALARM to the master VEVENT of the iTip message. + * + * @param ITip\Message $iTipMessage + * @param string $offset Signed integer (seconds, negative = before start), as stored by the calendar app. + */ + private function injectDefaultReminder(ITip\Message $iTipMessage, string $offset): void { + /** @var VEvent|null $vevent */ + $vevent = $iTipMessage->message->VEVENT ?? null; + if ($vevent === null) { + return; + } + $seconds = (int)$offset; + $duration = ($seconds < 0 ? '-' : '') . $this->secondsToIso8601Duration(abs($seconds)); + $alarm = $iTipMessage->message->createComponent('VALARM'); + $alarm->add($iTipMessage->message->createProperty('ACTION', 'DISPLAY')); + $alarm->add($iTipMessage->message->createProperty('DESCRIPTION', 'Reminder')); + $alarm->add($iTipMessage->message->createProperty('TRIGGER', $duration, ['RELATED' => 'START'])); + $vevent->add($alarm); + } + + /** + * Converts seconds to an ISO 8601 duration string. Helper derived from + * lufer22's nextcloud/server#48226. + * + * @param int $secs Non-negative. + * @return string + */ + private function secondsToIso8601Duration(int $secs): string { + $day = 24 * 60 * 60; + $hour = 60 * 60; + $minute = 60; + if ($secs === 0) { + return 'PT0S'; + } + if ($secs % $day === 0) { + return 'P' . (int)($secs / $day) . 'D'; + } + if ($secs % $hour === 0) { + return 'PT' . (int)($secs / $hour) . 'H'; + } + if ($secs % $minute === 0) { + return 'PT' . (int)($secs / $minute) . 'M'; + } + return 'PT' . $secs . 'S'; + } + + /** + * Maps a Sabre principal URI to a first-class Nextcloud user id, or null. + * + * @param string $principalUri e.g. 'principals/users/alice'. + * @return string|null + */ + private function principalUriToUserId(string $principalUri): ?string { + $prefix = 'principals/users/'; + if (!str_starts_with($principalUri, $prefix)) { + return null; + } + $userId = substr($principalUri, strlen($prefix)); + return $userId === '' ? null : $userId; + } } diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index ea4350bc1529d..cab08fce8b9a5 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -205,7 +205,7 @@ public function __construct( $this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class), \OCP\Server::get(RateLimiting::class))); $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); $this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger)); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class))); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class), \OCP\Server::get(\OCA\DAV\CalDAV\CalDavBackend::class), \OCP\Server::get(\OCP\Config\IUserConfig::class), \OCP\Server::get(\OCP\IAppConfig::class))); $this->server->addPlugin(\OCP\Server::get(\OCA\DAV\CalDAV\Trashbin\Plugin::class)); $this->server->addPlugin(new \OCA\DAV\CalDAV\WebcalCaching\Plugin($this->request)); diff --git a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php index 6f765ab21c817..7211e1e01e9f6 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php @@ -15,6 +15,8 @@ use OCA\DAV\CalDAV\Plugin as CalDAVPlugin; use OCA\DAV\CalDAV\Schedule\Plugin; use OCA\DAV\CalDAV\Trashbin\Plugin as TrashbinPlugin; +use OCP\Config\IUserConfig; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IL10N; use PHPUnit\Framework\MockObject\MockObject; @@ -42,6 +44,9 @@ class PluginTest extends TestCase { private IConfig&MockObject $config; private LoggerInterface&MockObject $logger; private DefaultCalendarValidator $calendarValidator; + private CalDavBackend&MockObject $caldavBackend; + private IUserConfig&MockObject $userConfig; + private IAppConfig&MockObject $appConfig; protected function setUp(): void { parent::setUp(); @@ -49,12 +54,15 @@ protected function setUp(): void { $this->config = $this->createMock(IConfig::class); $this->logger = $this->createMock(LoggerInterface::class); $this->calendarValidator = new DefaultCalendarValidator(); + $this->caldavBackend = $this->createMock(CalDavBackend::class); + $this->userConfig = $this->createMock(IUserConfig::class); + $this->appConfig = $this->createMock(IAppConfig::class); $this->server = $this->createMock(Server::class); $this->server->httpResponse = $this->createMock(ResponseInterface::class); $this->server->xml = new Service(); - $this->plugin = new Plugin($this->config, $this->logger, $this->calendarValidator); + $this->plugin = new Plugin($this->config, $this->logger, $this->calendarValidator, $this->caldavBackend, $this->userConfig, $this->appConfig); $this->plugin->initialize($this->server); } @@ -768,4 +776,286 @@ public function testCalendarObjectChangeWithSchedulingDisabled(): void { $newFlag ); } + + // ---------------------------------------------------------------------------- + // nextcloud/calendar#6315: receive-side default reminder + preserve-on-update + // ---------------------------------------------------------------------------- + + private function makeTestablePlugin(): TestableSchedulePlugin { + $plugin = new TestableSchedulePlugin( + $this->config, + $this->logger, + $this->calendarValidator, + $this->caldavBackend, + $this->userConfig, + $this->appConfig, + ); + $plugin->initialize($this->server); + return $plugin; + } + + /** + * Build an iTip\Message with one or more VEVENT components for testing + * scheduleLocalDelivery. + * + * @param string $method REQUEST|REPLY|CANCEL|REFRESH + * @param array $components Each item: ['rid' => null|string, 'isAllDay' => bool, 'valarms' => list of TRIGGER strings] + */ + private function makeITipMessage(string $method, array $components, string $organizerUri = 'mailto:organizer@example.org', string $recipientUri = 'mailto:test@example.org'): Message { + $vCal = new VCalendar(); + foreach ($components as $i => $comp) { + $rid = $comp['rid'] ?? null; + $isAllDay = $comp['isAllDay'] ?? false; + $valarms = $comp['valarms'] ?? []; + $vevent = $vCal->add('VEVENT', [ + 'UID' => 'UID-6315', + 'SUMMARY' => 'Test event', + 'DTSTART' => $isAllDay + ? new \DateTimeImmutable('2026-06-15') + : new \DateTimeImmutable('2026-06-15T09:00:00'), + 'DTSTAMP' => new \DateTimeImmutable('2026-06-10T10:00:00'), + 'ORGANIZER' => $organizerUri, + 'ATTENDEE' => $recipientUri, + ]); + if ($isAllDay) { + $vevent->DTSTART['VALUE'] = 'DATE'; + } + if ($rid !== null) { + $vevent->add('RECURRENCE-ID', $rid); + } + foreach ($valarms as $trigger) { + $alarm = $vevent->add('VALARM'); + $alarm->add('ACTION', 'DISPLAY'); + $alarm->add('TRIGGER', $trigger); + } + } + $msg = new Message(); + $msg->method = $method; + $msg->message = $vCal; + $msg->uid = 'UID-6315'; + $msg->recipient = $recipientUri; + $msg->sender = $organizerUri; + return $msg; + } + + /** + * Stub the ACL plugin lookup and getProperties call (for the recipient-user-type check). + */ + private function stubAclAndUserType(string $principalUri = 'principals/users/test', string $userType = 'INDIVIDUAL'): void { + $aclPlugin = $this->createMock(\Sabre\DAVACL\Plugin::class); + $aclPlugin->method('getPrincipalByUri')->willReturn($principalUri); + $this->server->method('getPlugin')->willReturnMap([ + ['acl', $aclPlugin], + ]); + $this->server->method('getProperties')->willReturn([ + '{' . Plugin::NS_CALDAV . '}calendar-user-type' => $userType, + ]); + } + + private function countValarms(VCalendar $vCal): int { + $n = 0; + foreach ($vCal->VEVENT as $vevent) { + if (isset($vevent->VALARM)) { + $n += count($vevent->VALARM); + } + } + return $n; + } + + public function testSkipsNonRequest(): void { + $plugin = $this->makeTestablePlugin(); + // No mocks needed for REPLY because we return before touching the reminder logic. + $msg = $this->makeITipMessage('REPLY', [['valarms' => ['-PT15M']]]); + $plugin->scheduleLocalDelivery($msg); + // Existing strip still runs on all VEVENTs regardless of method. + $this->assertSame(0, $this->countValarms($msg->message)); + } + + public function testStripsOrganizerValarmsFromAllComponents(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn(null); + // Toggle on, but every reminder source resolves to 'none' so nothing is injected. + $this->userConfig->method('getValueString')->willReturnMap([ + ['test', 'calendar', 'applyDefaultReminderToInvitations', 'yes', 'yes'], + ['test', 'calendar', 'defaultReminderPartDay', 'none', 'none'], + ['test', 'calendar', 'defaultReminder', 'none', 'none'], + ]); + $this->appConfig->method('getValueString')->willReturnMap([ + ['calendar', 'defaultReminder', 'none', 'none'], + ]); + + // Master + override, each with one organizer VALARM. + $msg = $this->makeITipMessage('REQUEST', [ + ['valarms' => ['PT0S']], + ['rid' => '20260616T090000Z', 'valarms' => ['PT0S']], + ]); + $plugin->scheduleLocalDelivery($msg); + + // All organizer VALARMs stripped, no default injected. + $this->assertSame(0, $this->countValarms($msg->message)); + } + + public function testFirstReceiptInjectsTypedPartDayDefault(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn(null); + $this->userConfig->method('getValueString')->willReturnMap([ + ['test', 'calendar', 'applyDefaultReminderToInvitations', 'yes', 'yes'], + ['test', 'calendar', 'defaultReminderPartDay', 'none', '-900'], + ]); + + $msg = $this->makeITipMessage('REQUEST', [['valarms' => []]]); + $plugin->scheduleLocalDelivery($msg); + + $this->assertSame(1, $this->countValarms($msg->message)); + $trigger = (string)$msg->message->VEVENT[0]->VALARM->TRIGGER; + $this->assertSame('-PT15M', $trigger); + } + + public function testFirstReceiptInjectsTypedFullDayDefault(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn(null); + $this->userConfig->method('getValueString')->willReturnMap([ + ['test', 'calendar', 'applyDefaultReminderToInvitations', 'yes', 'yes'], + ['test', 'calendar', 'defaultReminderFullDay', 'none', '-3600'], + ]); + + $msg = $this->makeITipMessage('REQUEST', [['isAllDay' => true]]); + $plugin->scheduleLocalDelivery($msg); + + $this->assertSame(1, $this->countValarms($msg->message)); + $this->assertSame('-PT1H', (string)$msg->message->VEVENT[0]->VALARM->TRIGGER); + } + + public function testFirstReceiptFallsBackToLegacyAndAppValue(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn(null); + // Toggle 'yes', typed PartDay 'none', legacy 'none' -> falls through to app value. + $this->userConfig->method('getValueString')->willReturnMap([ + ['test', 'calendar', 'applyDefaultReminderToInvitations', 'yes', 'yes'], + ['test', 'calendar', 'defaultReminderPartDay', 'none', 'none'], + ['test', 'calendar', 'defaultReminder', 'none', 'none'], + ]); + $this->appConfig->method('getValueString')->willReturnMap([ + ['calendar', 'defaultReminder', 'none', '-300'], + ]); + + $msg = $this->makeITipMessage('REQUEST', [[]]); + $plugin->scheduleLocalDelivery($msg); + + $this->assertSame(1, $this->countValarms($msg->message)); + $this->assertSame('-PT5M', (string)$msg->message->VEVENT[0]->VALARM->TRIGGER); + } + + public function testFirstReceiptSkippedWhenToggleOff(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn(null); + $this->userConfig->method('getValueString')->willReturnMap([ + ['test', 'calendar', 'applyDefaultReminderToInvitations', 'yes', 'no'], + ['test', 'calendar', 'defaultReminderPartDay', 'none', '-900'], + ]); + + $msg = $this->makeITipMessage('REQUEST', [[]]); + $plugin->scheduleLocalDelivery($msg); + + $this->assertSame(0, $this->countValarms($msg->message)); + } + + public function testPreservesExistingValarmsOnUpdate(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn('personal/UID-6315.ics'); + $this->caldavBackend->method('getCalendarByUri')->willReturn(['id' => 42]); + $this->caldavBackend->method('getCalendarObject')->willReturn([ + 'calendardata' => "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nUID:UID-6315\nDTSTART:20260615T090000Z\nDTSTAMP:20260610T100000Z\nBEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT45M\nDESCRIPTION:User reminder\nEND:VALARM\nEND:VEVENT\nEND:VCALENDAR\n", + ]); + // No user-config calls should happen on update path; nothing to mock. + + $msg = $this->makeITipMessage('REQUEST', [['valarms' => ['PT0S']]]); + $plugin->scheduleLocalDelivery($msg); + + // Organizer VALARM stripped, recipient's existing -PT45M preserved. + $this->assertSame(1, $this->countValarms($msg->message)); + $this->assertSame('-PT45M', (string)$msg->message->VEVENT[0]->VALARM->TRIGGER); + } + + public function testExistingCopyWithoutAlarmsSkipsInjection(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn('personal/UID-6315.ics'); + $this->caldavBackend->method('getCalendarByUri')->willReturn(['id' => 42]); + $this->caldavBackend->method('getCalendarObject')->willReturn([ + 'calendardata' => "BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nUID:UID-6315\nDTSTART:20260615T090000Z\nDTSTAMP:20260610T100000Z\nEND:VEVENT\nEND:VCALENDAR\n", + ]); + // Recipient deleted their reminder; an organizer update must not re-inject a default. + $this->userConfig->expects($this->never())->method('getValueString'); + + $msg = $this->makeITipMessage('REQUEST', [['valarms' => ['PT0S']]]); + $plugin->scheduleLocalDelivery($msg); + + $this->assertSame(0, $this->countValarms($msg->message)); + } + + public function testPreservesPerOverrideValarms(): void { + $plugin = $this->makeTestablePlugin(); + $this->stubAclAndUserType(); + $this->caldavBackend->method('getCalendarObjectByUID')->willReturn('personal/UID-6315.ics'); + $this->caldavBackend->method('getCalendarByUri')->willReturn(['id' => 42]); + $this->caldavBackend->method('getCalendarObject')->willReturn([ + 'calendardata' => "BEGIN:VCALENDAR\nVERSION:2.0\n" + . "BEGIN:VEVENT\nUID:UID-6315\nDTSTART:20260615T090000Z\nDTSTAMP:20260610T100000Z\n" + . "BEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT15M\nDESCRIPTION:r\nEND:VALARM\nEND:VEVENT\n" + . "BEGIN:VEVENT\nUID:UID-6315\nDTSTART:20260616T090000Z\nDTSTAMP:20260610T100000Z\nRECURRENCE-ID:20260616T090000Z\n" + . "BEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT5M\nDESCRIPTION:r\nEND:VALARM\nEND:VEVENT\n" + . "END:VCALENDAR\n", + ]); + + // Incoming master + same override, each with organizer VALARM that must be stripped. + $msg = $this->makeITipMessage('REQUEST', [ + ['valarms' => ['PT0S']], + ['rid' => '20260616T090000Z', 'valarms' => ['PT0S']], + ]); + $plugin->scheduleLocalDelivery($msg); + + $this->assertSame(2, $this->countValarms($msg->message)); + // Master kept its existing -PT15M, override kept its existing -PT5M. + $this->assertSame('-PT15M', (string)$msg->message->VEVENT[0]->VALARM->TRIGGER); + $this->assertSame('-PT5M', (string)$msg->message->VEVENT[1]->VALARM->TRIGGER); + } + + public function testSecondsToIso8601Duration(): void { + $plugin = $this->makeTestablePlugin(); + $ref = new \ReflectionMethod($plugin, 'secondsToIso8601Duration'); + $ref->setAccessible(true); + $this->assertSame('PT0S', $ref->invoke($plugin, 0)); + $this->assertSame('PT30S', $ref->invoke($plugin, 30)); + $this->assertSame('PT15M', $ref->invoke($plugin, 900)); + $this->assertSame('PT1H', $ref->invoke($plugin, 3600)); + $this->assertSame('P1D', $ref->invoke($plugin, 86400)); + $this->assertSame('P7D', $ref->invoke($plugin, 604800)); + } + + public function testPrincipalUriToUserId(): void { + $plugin = $this->makeTestablePlugin(); + $ref = new \ReflectionMethod($plugin, 'principalUriToUserId'); + $ref->setAccessible(true); + $this->assertSame('alice', $ref->invoke($plugin, 'principals/users/alice')); + $this->assertNull($ref->invoke($plugin, 'principals/groups/staff')); + $this->assertNull($ref->invoke($plugin, 'principals/users/')); + $this->assertNull($ref->invoke($plugin, 'other/path')); + } +} + +/** + * Subclass that no-ops the delegation to Sabre's parent::scheduleLocalDelivery, + * so test cases can exercise the NC hook logic without setting up a full DAV tree. + */ +class TestableSchedulePlugin extends Plugin { + protected function delegateToSabre(Message $iTipMessage): void { + // no-op + } }