From 6354c8b4c565fa84bf7a6cd8fe9e500fdde15c76 Mon Sep 17 00:00:00 2001 From: Adrian Stephens Date: Fri, 3 Apr 2026 10:28:30 +0100 Subject: [PATCH] Improve private-event handling and CalDAV sync stability --- code/src/Domain/CalDavService.php | 8 + code/src/Domain/EventService.php | 111 +++++++- code/src/Domain/IcsService.php | 57 +++- code/src/Domain/RecurrenceExpander.php | 1 + .../WordPress/MigrationManager.php | 19 +- code/src/Plugin.php | 94 ++++++- .../calendar-plugin-0.1.15.manifest.sha256 | 40 +-- package/calendar-plugin-0.1.15.zip | Bin 52177 -> 53958 bytes .../src/Domain/CalDavService.php | 8 + .../src/Domain/EventService.php | 111 +++++++- .../calendar-plugin/src/Domain/IcsService.php | 57 +++- .../src/Domain/RecurrenceExpander.php | 1 + .../WordPress/MigrationManager.php | 19 +- .../staging/calendar-plugin/src/Plugin.php | 94 ++++++- requirements/caldav.md | 3 + requirements/caldav_endpoints.md | 7 + requirements/test_strategy.md | 4 + scripts/deploy_remote.sh | 8 +- tests/api_test_cases.md | 25 ++ tests/run_remote_tests.sh | 249 +++++++++++++++++- tests/smoke_tests.md | 3 + 21 files changed, 836 insertions(+), 83 deletions(-) diff --git a/code/src/Domain/CalDavService.php b/code/src/Domain/CalDavService.php index a6689df..a8f78ef 100644 --- a/code/src/Domain/CalDavService.php +++ b/code/src/Domain/CalDavService.php @@ -140,6 +140,14 @@ final class CalDavService return $out; } + public function listDeletedResources(int $limit = 500): array + { + $rows = $this->events->listCalDavTombstones($limit); + return array_values(array_filter(array_map(static function (array $row): string { + return trim((string) ($row['resource'] ?? '')); + }, $rows))); + } + public function resourceForEvent(array $event): string { $resource = trim((string) ($event['caldav_resource'] ?? '')); diff --git a/code/src/Domain/EventService.php b/code/src/Domain/EventService.php index 7e99a9b..5deb849 100644 --- a/code/src/Domain/EventService.php +++ b/code/src/Domain/EventService.php @@ -12,6 +12,7 @@ final class EventService { private readonly string $eventsTable; private readonly string $exceptionsTable; + private readonly string $tombstonesTable; public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar') { @@ -19,6 +20,7 @@ final class EventService $stem = trim($tableStem, '_'); $this->eventsTable = $prefix . $stem . '_events'; $this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions'; + $this->tombstonesTable = $prefix . $stem . '_caldav_tombstones'; } public function listEvents(): array @@ -75,6 +77,7 @@ final class EventService $data = [ 'uid' => $uid, + 'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')), 'title' => $title, 'description' => (string) ($payload['description'] ?? ''), 'location' => (string) ($payload['location'] ?? ''), @@ -104,7 +107,12 @@ final class EventService if ($inserted === false) { throw new \RuntimeException('failed to create event'); } - return (array) $this->getEvent($this->db->insertId()); + $created = (array) $this->getEvent($this->db->insertId()); + $resource = trim((string) ($created['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->clearCalDavTombstone($resource); + } + return $created; } public function updateEvent(int $id, array $payload): ?array @@ -148,6 +156,9 @@ final class EventService $repeatNthWeekday ); $data = [ + 'visibility' => array_key_exists('visibility', $payload) + ? $this->canonicalVisibility((string) $payload['visibility']) + : $this->canonicalVisibility((string) ($existing['visibility'] ?? 'public')), 'title' => trim((string) ($payload['title'] ?? $existing['title'])), 'description' => (string) ($payload['description'] ?? $existing['description']), 'location' => (string) ($payload['location'] ?? $existing['location']), @@ -179,13 +190,27 @@ final class EventService ]; $this->db->update($this->eventsTable, $data, ['id' => $id]); - return $this->getEvent($id); + $updated = $this->getEvent($id); + if ($updated) { + $resource = trim((string) ($updated['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->clearCalDavTombstone($resource); + } + } + return $updated; } public function deleteEvent(int $id): bool { + $event = $this->getEvent($id); $this->db->delete($this->exceptionsTable, ['event_id' => $id]); $deleted = $this->db->delete($this->eventsTable, ['id' => $id]); + if ($deleted !== false && $event) { + $resource = trim((string) ($event['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->recordCalDavTombstone($resource); + } + } return $deleted !== false; } @@ -278,6 +303,7 @@ final class EventService $event = [ 'id' => 0, 'uid' => 'preview@calendar-plugin', + 'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')), 'title' => (string) ($payload['title'] ?? ''), 'description' => (string) ($payload['description'] ?? ''), 'location' => (string) ($payload['location'] ?? ''), @@ -352,7 +378,7 @@ final class EventService } } - public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array + public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array { $tz = new DateTimeZone('Europe/London'); $anchor = $this->safeDate($dateAnchor, $tz); @@ -382,10 +408,14 @@ final class EventService static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start']) ); + if ($redactPrivate) { + return array_map([$this, 'redactOccurrenceForPublic'], $out); + } + return $out; } - public function listSidebarUpcoming(int $days = 14): array + public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array { $tz = new DateTimeZone('Europe/London'); $start = new DateTimeImmutable('today', $tz); @@ -404,6 +434,10 @@ final class EventService static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start']) ); + if ($redactPrivate) { + return array_map([$this, 'redactOccurrenceForPublic'], $out); + } + return $out; } @@ -411,11 +445,31 @@ final class EventService { $events = $this->listEvents(); $count = count($events); + foreach ($events as $event) { + $resource = trim((string) ($event['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->recordCalDavTombstone($resource); + } + } $this->db->query("DELETE FROM {$this->exceptionsTable}"); $this->db->query("DELETE FROM {$this->eventsTable}"); return $count; } + public function listCalDavTombstones(int $limit = 500): array + { + $limit = max(1, min($limit, 5000)); + $rows = $this->db->getResults( + "SELECT resource, deleted_at FROM {$this->tombstonesTable} ORDER BY deleted_at DESC LIMIT {$limit}" + ); + return array_map(static function (object $row): array { + return [ + 'resource' => (string) ($row->resource ?? ''), + 'deleted_at' => (string) ($row->deleted_at ?? ''), + ]; + }, $rows); + } + public function seedDefaultEvents(): int { $seed = [ @@ -494,6 +548,9 @@ final class EventService return [ 'id' => (int) $row->id, 'uid' => (string) $row->uid, + 'visibility' => property_exists($row, 'visibility') + ? $this->canonicalVisibility((string) ($row->visibility ?? 'public')) + : 'public', 'title' => (string) $row->title, 'description' => (string) $row->description, 'location' => (string) $row->location, @@ -614,6 +671,26 @@ final class EventService return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none'; } + private function canonicalVisibility(string $value): string + { + $visibility = strtolower(trim($value)); + return $visibility === 'private' ? 'private' : 'public'; + } + + private function redactOccurrenceForPublic(array $occurrence): array + { + if ($this->canonicalVisibility((string) ($occurrence['visibility'] ?? 'public')) !== 'private') { + return $occurrence; + } + + $occurrence['title'] = 'Private Event'; + $occurrence['description'] = ''; + $occurrence['location'] = ''; + $occurrence['category'] = ''; + + return $occurrence; + } + private function normalizeMonthlyAnchor( string $startIso, string $endIso, @@ -706,4 +783,30 @@ final class EventService } return [$newYear, $newMonth]; } + + private function clearCalDavTombstone(string $resource): void + { + $resource = trim($resource); + if ($resource === '') { + return; + } + $this->db->delete($this->tombstonesTable, ['resource' => $resource]); + } + + private function recordCalDavTombstone(string $resource): void + { + $resource = trim($resource); + if ($resource === '') { + return; + } + $now = gmdate('c'); + $this->db->delete($this->tombstonesTable, ['resource' => $resource]); + $this->db->insert( + $this->tombstonesTable, + [ + 'resource' => $resource, + 'deleted_at' => $now, + ] + ); + } } diff --git a/code/src/Domain/IcsService.php b/code/src/Domain/IcsService.php index c974626..7927cdf 100644 --- a/code/src/Domain/IcsService.php +++ b/code/src/Domain/IcsService.php @@ -11,7 +11,12 @@ final class IcsService { private const PRODID = '-//Calendar Plugin//EN'; - public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string + public function buildCalendar( + array $events, + callable $deletedKeysProvider, + string $calendarName = 'Calendar', + bool $redactPrivate = false + ): string { $lines = [ 'BEGIN:VCALENDAR', @@ -20,10 +25,35 @@ final class IcsService 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:' . $this->escapeText($calendarName), 'X-WR-TIMEZONE:Europe/London', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/London', + 'X-LIC-LOCATION:Europe/London', + 'BEGIN:DAYLIGHT', + 'TZOFFSETFROM:+0000', + 'TZOFFSETTO:+0100', + 'TZNAME:BST', + 'DTSTART:19700329T010000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'BEGIN:STANDARD', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0000', + 'TZNAME:GMT', + 'DTSTART:19701025T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'END:VTIMEZONE', ]; foreach ($events as $event) { - $lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0)))); + $lines = array_merge( + $lines, + $this->eventToLines( + $event, + (array) $deletedKeysProvider((int) ($event['id'] ?? 0)), + $redactPrivate + ) + ); } $lines[] = 'END:VCALENDAR'; @@ -76,6 +106,11 @@ final class IcsService 'repeat_until' => null, 'timezone' => 'Europe/London', ]; + if (isset($props['CLASS'][0])) { + $payload['visibility'] = strtoupper((string) $props['CLASS'][0]) === 'PRIVATE' ? 'private' : 'public'; + } elseif (isset($props['X-CALENDARSERVER-ACCESS'][0])) { + $payload['visibility'] = strtoupper((string) $props['X-CALENDARSERVER-ACCESS'][0]) === 'PRIVATE' ? 'private' : 'public'; + } $rrule = (string) ($props['RRULE'][0] ?? ''); if ($rrule !== '') { @@ -97,7 +132,7 @@ final class IcsService return $payload; } - private function eventToLines(array $event, array $deletedKeys): array + private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array { $uid = (string) ($event['uid'] ?? ''); $uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin'); @@ -109,18 +144,26 @@ final class IcsService } $allDay = (bool) ($event['all_day_event'] ?? false); + $visibility = strtolower(trim((string) ($event['visibility'] ?? 'public'))) === 'private' ? 'private' : 'public'; + $isRedactedPrivate = $redactPrivate && $visibility === 'private'; $updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC')); $lines = [ 'BEGIN:VEVENT', 'UID:' . $this->escapeText($uid), - 'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')), - 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')), - 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')), - 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')), + 'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')), 'DTSTAMP:' . $this->toUtcIcs($updated), 'LAST-MODIFIED:' . $this->toUtcIcs($updated), ]; + if (!$isRedactedPrivate) { + $icsVisibility = strtoupper($visibility === 'private' ? 'PRIVATE' : 'PUBLIC'); + $lines[] = 'CLASS:' . $icsVisibility; + // Compatibility hint for clients that rely on CalendarServer-style access fields. + $lines[] = 'X-CALENDARSERVER-ACCESS:' . $icsVisibility; + $lines[] = 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')); + $lines[] = 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')); + $lines[] = 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')); + } if ($allDay) { $lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd'); diff --git a/code/src/Domain/RecurrenceExpander.php b/code/src/Domain/RecurrenceExpander.php index 71fe24e..0fd8bd8 100644 --- a/code/src/Domain/RecurrenceExpander.php +++ b/code/src/Domain/RecurrenceExpander.php @@ -148,6 +148,7 @@ final class RecurrenceExpander return [ 'event_id' => (int) ($event['id'] ?? 0), 'uid' => (string) ($event['uid'] ?? ''), + 'visibility' => (string) ($event['visibility'] ?? 'public'), 'title' => (string) ($event['title'] ?? ''), 'description' => (string) ($event['description'] ?? ''), 'location' => (string) ($event['location'] ?? ''), diff --git a/code/src/Infrastructure/WordPress/MigrationManager.php b/code/src/Infrastructure/WordPress/MigrationManager.php index cb2281c..6537806 100644 --- a/code/src/Infrastructure/WordPress/MigrationManager.php +++ b/code/src/Infrastructure/WordPress/MigrationManager.php @@ -10,7 +10,7 @@ use DateTimeZone; final class MigrationManager { - private const SCHEMA_VERSION = '3'; + private const SCHEMA_VERSION = '5'; private const STEM_OPTION = 'calendar_plugin_table_stem'; public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar') @@ -31,10 +31,12 @@ final class MigrationManager $users = $prefix . $stem . '_users'; $tokens = $prefix . $stem . '_user_tokens'; $audit = $prefix . $stem . '_audit_log'; + $tombstones = $prefix . $stem . '_caldav_tombstones'; $sqlEvents = "CREATE TABLE {$events} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, uid VARCHAR(191) NOT NULL, + visibility VARCHAR(16) NOT NULL DEFAULT 'public', title TEXT NOT NULL, description LONGTEXT NOT NULL, location TEXT NOT NULL, @@ -115,11 +117,18 @@ final class MigrationManager KEY created_at (created_at) ) {$charsetCollate};"; + $sqlTombstones = "CREATE TABLE {$tombstones} ( + resource VARCHAR(191) NOT NULL, + deleted_at VARCHAR(32) NOT NULL, + PRIMARY KEY (resource) + ) {$charsetCollate};"; + dbDelta($sqlEvents); dbDelta($sqlExceptions); dbDelta($sqlUsers); dbDelta($sqlTokens); dbDelta($sqlAudit); + dbDelta($sqlTombstones); // Ensure every event has a stable CalDAV object resource name. $this->db->query( @@ -129,6 +138,13 @@ final class MigrationManager AND uid IS NOT NULL AND uid <> ''" ); + $this->db->query( + "UPDATE {$events} + SET visibility = 'public' + WHERE visibility IS NULL + OR visibility = '' + OR visibility NOT IN ('public', 'private')" + ); $this->normalizeEventDateTimesToLondon($events); update_option(self::STEM_OPTION, $stem); @@ -170,6 +186,7 @@ final class MigrationManager $prefix . $stem . '_users', $prefix . $stem . '_user_tokens', $prefix . $stem . '_audit_log', + $prefix . $stem . '_caldav_tombstones', ]; foreach ($tables as $table) { $sql = $this->db->prepare('SHOW TABLES LIKE %s', $table); diff --git a/code/src/Plugin.php b/code/src/Plugin.php index 42c77db..8b573ea 100644 --- a/code/src/Plugin.php +++ b/code/src/Plugin.php @@ -209,6 +209,7 @@ final class Plugin
+
@@ -256,6 +257,7 @@ final class Plugin

Event Details

+
@@ -409,6 +411,7 @@ final class Plugin const clearEditor=()=>{ s("cp-event-id").value=""; ["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value=""); + s("cp-visibility").value="public"; s("cp-occurrence-key-iso").value=""; s("cp-all-day").checked=false; s("cp-repeat-type").value="none"; @@ -452,6 +455,7 @@ final class Plugin const openDetailsForItem=(it)=>{ s("cp-details-title").value=it.title||""; + s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public"; s("cp-details-category").value=it.category||""; s("cp-details-location").value=it.location||""; s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event); @@ -478,6 +482,7 @@ final class Plugin s("cp-editor-title").textContent="Edit Event"; s("cp-event-id").value=itemId(source); s("cp-title").value=source.title||""; + s("cp-visibility").value=source.visibility==="private"?"private":"public"; s("cp-description").value=source.description||""; s("cp-location").value=source.location||""; s("cp-category").value=source.category||""; @@ -532,6 +537,7 @@ final class Plugin ok:true, payload:{ title:title, + visibility:s("cp-visibility").value==="private"?"private":"public", description:s("cp-description").value, location:s("cp-location").value, category:s("cp-category").value, @@ -1592,10 +1598,16 @@ HTML $date = (string) ($request->get_param('date') ?: gmdate('Y-m-d')); $futureOnlyRaw = (string) ($request->get_param('future_only') ?? ''); $futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true); - $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly); + $includePrivateDetails = $this->canWriteCalendar($request); + $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails); return [ 'data' => $items, - 'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly], + 'meta' => [ + 'count' => count($items), + 'view' => $view, + 'future_only' => $futureOnly, + 'redacted_private' => !$includePrivateDetails, + ], ]; }, ] @@ -1608,7 +1620,7 @@ HTML 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => function (): array { - $items = $this->eventService->listSidebarUpcoming(14); + $items = $this->eventService->listSidebarUpcoming(14, true); return [ 'data' => $items, 'meta' => ['count' => count($items), 'window_days' => 14], @@ -1840,13 +1852,19 @@ HTML [ 'methods' => 'GET', 'permission_callback' => '__return_true', - 'callback' => function (): array { + 'callback' => function ($request): array|\WP_Error { $settings = $this->settingsService->getAll(); + $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read'); + $includePrivateDetails = $this->canWriteCalendar($request); + if ($icsMode === 'authenticated_read' && !$includePrivateDetails) { + return $this->error('auth_required', 'authentication required', 401); + } $calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar'); $ics = $this->icsService->buildCalendar( $this->eventService->listEvents(), fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId), - $calendarName + $calendarName, + !$includePrivateDetails ); return ['data' => $ics]; }, @@ -1964,9 +1982,11 @@ HTML private function serveIcsResponse(): void { $settings = $this->settingsService->getAll(); + $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read'); + $includePrivateDetails = $this->canWriteCalendar(null); if ( - (string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read' - && $this->resolveCalDavUserForRequest(null) === null + $icsMode === 'authenticated_read' + && !$includePrivateDetails ) { http_response_code(401); header('Content-Type: application/json; charset=utf-8'); @@ -1978,7 +1998,8 @@ HTML $ics = $this->icsService->buildCalendar( $this->eventService->listEvents(), fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId), - $calendarName + $calendarName, + !$includePrivateDetails ); $etag = '"' . substr(sha1($ics), 0, 16) . '"'; $lastModified = gmdate('D, d M Y H:i:s') . ' GMT'; @@ -2010,7 +2031,18 @@ HTML $resourcePrefix = $collection; if ($method === 'HEAD') { - if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) { + if ( + $path === $root + || $path === $root . '/' + || $path === $principalCollection + || $path === rtrim($principalCollection, '/') + || $path === $principal + || $path === rtrim($principal, '/') + || $path === $calendarsRoot + || $path === rtrim($calendarsRoot, '/') + || $path === $collection + || $path === rtrim($collection, '/') + ) { header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD'); header('DAV: 1, calendar-access'); http_response_code(200); @@ -2030,6 +2062,26 @@ HTML } } + if ($method === 'GET') { + if ( + $path === $root + || $path === $root . '/' + || $path === $principalCollection + || $path === rtrim($principalCollection, '/') + || $path === $principal + || $path === rtrim($principal, '/') + || $path === $calendarsRoot + || $path === rtrim($calendarsRoot, '/') + || $path === $collection + || $path === rtrim($collection, '/') + ) { + header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD'); + header('DAV: 1, calendar-access'); + http_response_code(200); + return; + } + } + if ($method === 'OPTIONS') { header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD'); header('DAV: 1, calendar-access'); @@ -2079,7 +2131,7 @@ HTML return; } - if ($method === 'REPORT' && $path === $collection) { + if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) { $body = (string) file_get_contents('php://input'); header('Content-Type: application/xml; charset=utf-8'); http_response_code(207); @@ -2269,7 +2321,15 @@ HTML $items = []; if (str_contains($bodyLower, 'sync-collection')) { - $items = $this->calDavService->multiget($resources); + $clientSyncToken = $this->extractSyncCollectionToken($xmlBody); + // If client token is already current, no changes should be emitted. + if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) { + $items = []; + } else { + // Fallback implementation: emit current objects for initial/out-of-date tokens. + // Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures. + $items = $this->calDavService->multiget($resources); + } } elseif (str_contains($bodyLower, 'calendar-query')) { $items = $this->calDavService->multiget($resources); if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) { @@ -2324,6 +2384,9 @@ HTML foreach ($rows as $row) { $seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';'; } + foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) { + $seed .= 'deleted:' . $deletedResource . ';'; + } return 'urn:calendar-plugin:sync:' . sha1($seed); } @@ -2353,6 +2416,15 @@ HTML return $this->icalToIso((string) $m[1]); } + private function extractSyncCollectionToken(string $xmlBody): string + { + if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) { + return ''; + } + $token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8')); + return $token; + } + private function unwrapServiceResult(array $result): array|\WP_Error { if (isset($result['error']) && is_array($result['error'])) { diff --git a/package/calendar-plugin-0.1.15.manifest.sha256 b/package/calendar-plugin-0.1.15.manifest.sha256 index fb05654..99df439 100644 --- a/package/calendar-plugin-0.1.15.manifest.sha256 +++ b/package/calendar-plugin-0.1.15.manifest.sha256 @@ -1,20 +1,20 @@ -ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e staging/calendar-plugin/calendar-plugin.php -1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb staging/calendar-plugin/src/Contracts/AuthAdapterInterface.php -25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d staging/calendar-plugin/src/Contracts/DatabaseAdapterInterface.php -4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba staging/calendar-plugin/src/Contracts/HttpAdapterInterface.php -15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 staging/calendar-plugin/src/Contracts/OptionsAdapterInterface.php -47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 staging/calendar-plugin/src/Domain/CalDavService.php -4dc3337761c97aa550896fcc377aaab8338c598f39e489742dacbfd20e1a71b1 staging/calendar-plugin/src/Domain/EventService.php -412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f staging/calendar-plugin/src/Domain/IcsService.php -5f7fb1f8c00136ad2c4a2dc8b909dabf6494a194b09aa73557a4bf68d73f4ed2 staging/calendar-plugin/src/Domain/RecurrenceExpander.php -20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 staging/calendar-plugin/src/Domain/SettingsService.php -9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f staging/calendar-plugin/src/Domain/UserService.php -5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b staging/calendar-plugin/src/Infrastructure/ServiceContainer.php -70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php -8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 staging/calendar-plugin/src/Infrastructure/WordPress/WordPressAuthAdapter.php -68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c staging/calendar-plugin/src/Infrastructure/WordPress/WordPressDatabaseAdapter.php -8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b staging/calendar-plugin/src/Infrastructure/WordPress/WordPressHttpAdapter.php -cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea staging/calendar-plugin/src/Infrastructure/WordPress/WordPressOptionsAdapter.php -ffc7c3eef8f3ed0873b5f614765219925d07c24ffd6ffa7422a3d862a7342450 staging/calendar-plugin/src/Plugin.php -4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 staging/calendar-plugin/src/bootstrap.php -893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c staging/calendar-plugin/uninstall.php +ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e ./calendar-plugin.php +1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php +25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php +4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php +15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php +da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php +c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php +f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php +fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php +20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php +9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php +5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php +e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php +8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php +68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php +8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php +cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php +0cc96b252ac86e91a724e73beecfe6f57a1afdb6f5d9a26e44aed00a3f0e20f0 ./src/Plugin.php +4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php +893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php diff --git a/package/calendar-plugin-0.1.15.zip b/package/calendar-plugin-0.1.15.zip index 1e69edd1d3435e67dd679f24e2d68283bafd93d9..d501ca0a4ed68042cf6228cdcafb4ecc6217a784 100644 GIT binary patch delta 40931 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 Bvh@G} delta 39195 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%0DyAWxGRHGIlyzw^8i!Os$bM_OhZBF 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 diff --git a/package/staging/calendar-plugin/src/Domain/CalDavService.php b/package/staging/calendar-plugin/src/Domain/CalDavService.php index a6689df..a8f78ef 100644 --- a/package/staging/calendar-plugin/src/Domain/CalDavService.php +++ b/package/staging/calendar-plugin/src/Domain/CalDavService.php @@ -140,6 +140,14 @@ final class CalDavService return $out; } + public function listDeletedResources(int $limit = 500): array + { + $rows = $this->events->listCalDavTombstones($limit); + return array_values(array_filter(array_map(static function (array $row): string { + return trim((string) ($row['resource'] ?? '')); + }, $rows))); + } + public function resourceForEvent(array $event): string { $resource = trim((string) ($event['caldav_resource'] ?? '')); diff --git a/package/staging/calendar-plugin/src/Domain/EventService.php b/package/staging/calendar-plugin/src/Domain/EventService.php index 7e99a9b..5deb849 100644 --- a/package/staging/calendar-plugin/src/Domain/EventService.php +++ b/package/staging/calendar-plugin/src/Domain/EventService.php @@ -12,6 +12,7 @@ final class EventService { private readonly string $eventsTable; private readonly string $exceptionsTable; + private readonly string $tombstonesTable; public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar') { @@ -19,6 +20,7 @@ final class EventService $stem = trim($tableStem, '_'); $this->eventsTable = $prefix . $stem . '_events'; $this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions'; + $this->tombstonesTable = $prefix . $stem . '_caldav_tombstones'; } public function listEvents(): array @@ -75,6 +77,7 @@ final class EventService $data = [ 'uid' => $uid, + 'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')), 'title' => $title, 'description' => (string) ($payload['description'] ?? ''), 'location' => (string) ($payload['location'] ?? ''), @@ -104,7 +107,12 @@ final class EventService if ($inserted === false) { throw new \RuntimeException('failed to create event'); } - return (array) $this->getEvent($this->db->insertId()); + $created = (array) $this->getEvent($this->db->insertId()); + $resource = trim((string) ($created['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->clearCalDavTombstone($resource); + } + return $created; } public function updateEvent(int $id, array $payload): ?array @@ -148,6 +156,9 @@ final class EventService $repeatNthWeekday ); $data = [ + 'visibility' => array_key_exists('visibility', $payload) + ? $this->canonicalVisibility((string) $payload['visibility']) + : $this->canonicalVisibility((string) ($existing['visibility'] ?? 'public')), 'title' => trim((string) ($payload['title'] ?? $existing['title'])), 'description' => (string) ($payload['description'] ?? $existing['description']), 'location' => (string) ($payload['location'] ?? $existing['location']), @@ -179,13 +190,27 @@ final class EventService ]; $this->db->update($this->eventsTable, $data, ['id' => $id]); - return $this->getEvent($id); + $updated = $this->getEvent($id); + if ($updated) { + $resource = trim((string) ($updated['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->clearCalDavTombstone($resource); + } + } + return $updated; } public function deleteEvent(int $id): bool { + $event = $this->getEvent($id); $this->db->delete($this->exceptionsTable, ['event_id' => $id]); $deleted = $this->db->delete($this->eventsTable, ['id' => $id]); + if ($deleted !== false && $event) { + $resource = trim((string) ($event['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->recordCalDavTombstone($resource); + } + } return $deleted !== false; } @@ -278,6 +303,7 @@ final class EventService $event = [ 'id' => 0, 'uid' => 'preview@calendar-plugin', + 'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')), 'title' => (string) ($payload['title'] ?? ''), 'description' => (string) ($payload['description'] ?? ''), 'location' => (string) ($payload['location'] ?? ''), @@ -352,7 +378,7 @@ final class EventService } } - public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array + public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array { $tz = new DateTimeZone('Europe/London'); $anchor = $this->safeDate($dateAnchor, $tz); @@ -382,10 +408,14 @@ final class EventService static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start']) ); + if ($redactPrivate) { + return array_map([$this, 'redactOccurrenceForPublic'], $out); + } + return $out; } - public function listSidebarUpcoming(int $days = 14): array + public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array { $tz = new DateTimeZone('Europe/London'); $start = new DateTimeImmutable('today', $tz); @@ -404,6 +434,10 @@ final class EventService static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start']) ); + if ($redactPrivate) { + return array_map([$this, 'redactOccurrenceForPublic'], $out); + } + return $out; } @@ -411,11 +445,31 @@ final class EventService { $events = $this->listEvents(); $count = count($events); + foreach ($events as $event) { + $resource = trim((string) ($event['caldav_resource'] ?? '')); + if ($resource !== '') { + $this->recordCalDavTombstone($resource); + } + } $this->db->query("DELETE FROM {$this->exceptionsTable}"); $this->db->query("DELETE FROM {$this->eventsTable}"); return $count; } + public function listCalDavTombstones(int $limit = 500): array + { + $limit = max(1, min($limit, 5000)); + $rows = $this->db->getResults( + "SELECT resource, deleted_at FROM {$this->tombstonesTable} ORDER BY deleted_at DESC LIMIT {$limit}" + ); + return array_map(static function (object $row): array { + return [ + 'resource' => (string) ($row->resource ?? ''), + 'deleted_at' => (string) ($row->deleted_at ?? ''), + ]; + }, $rows); + } + public function seedDefaultEvents(): int { $seed = [ @@ -494,6 +548,9 @@ final class EventService return [ 'id' => (int) $row->id, 'uid' => (string) $row->uid, + 'visibility' => property_exists($row, 'visibility') + ? $this->canonicalVisibility((string) ($row->visibility ?? 'public')) + : 'public', 'title' => (string) $row->title, 'description' => (string) $row->description, 'location' => (string) $row->location, @@ -614,6 +671,26 @@ final class EventService return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none'; } + private function canonicalVisibility(string $value): string + { + $visibility = strtolower(trim($value)); + return $visibility === 'private' ? 'private' : 'public'; + } + + private function redactOccurrenceForPublic(array $occurrence): array + { + if ($this->canonicalVisibility((string) ($occurrence['visibility'] ?? 'public')) !== 'private') { + return $occurrence; + } + + $occurrence['title'] = 'Private Event'; + $occurrence['description'] = ''; + $occurrence['location'] = ''; + $occurrence['category'] = ''; + + return $occurrence; + } + private function normalizeMonthlyAnchor( string $startIso, string $endIso, @@ -706,4 +783,30 @@ final class EventService } return [$newYear, $newMonth]; } + + private function clearCalDavTombstone(string $resource): void + { + $resource = trim($resource); + if ($resource === '') { + return; + } + $this->db->delete($this->tombstonesTable, ['resource' => $resource]); + } + + private function recordCalDavTombstone(string $resource): void + { + $resource = trim($resource); + if ($resource === '') { + return; + } + $now = gmdate('c'); + $this->db->delete($this->tombstonesTable, ['resource' => $resource]); + $this->db->insert( + $this->tombstonesTable, + [ + 'resource' => $resource, + 'deleted_at' => $now, + ] + ); + } } diff --git a/package/staging/calendar-plugin/src/Domain/IcsService.php b/package/staging/calendar-plugin/src/Domain/IcsService.php index c974626..7927cdf 100644 --- a/package/staging/calendar-plugin/src/Domain/IcsService.php +++ b/package/staging/calendar-plugin/src/Domain/IcsService.php @@ -11,7 +11,12 @@ final class IcsService { private const PRODID = '-//Calendar Plugin//EN'; - public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string + public function buildCalendar( + array $events, + callable $deletedKeysProvider, + string $calendarName = 'Calendar', + bool $redactPrivate = false + ): string { $lines = [ 'BEGIN:VCALENDAR', @@ -20,10 +25,35 @@ final class IcsService 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:' . $this->escapeText($calendarName), 'X-WR-TIMEZONE:Europe/London', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/London', + 'X-LIC-LOCATION:Europe/London', + 'BEGIN:DAYLIGHT', + 'TZOFFSETFROM:+0000', + 'TZOFFSETTO:+0100', + 'TZNAME:BST', + 'DTSTART:19700329T010000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'BEGIN:STANDARD', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0000', + 'TZNAME:GMT', + 'DTSTART:19701025T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'END:VTIMEZONE', ]; foreach ($events as $event) { - $lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0)))); + $lines = array_merge( + $lines, + $this->eventToLines( + $event, + (array) $deletedKeysProvider((int) ($event['id'] ?? 0)), + $redactPrivate + ) + ); } $lines[] = 'END:VCALENDAR'; @@ -76,6 +106,11 @@ final class IcsService 'repeat_until' => null, 'timezone' => 'Europe/London', ]; + if (isset($props['CLASS'][0])) { + $payload['visibility'] = strtoupper((string) $props['CLASS'][0]) === 'PRIVATE' ? 'private' : 'public'; + } elseif (isset($props['X-CALENDARSERVER-ACCESS'][0])) { + $payload['visibility'] = strtoupper((string) $props['X-CALENDARSERVER-ACCESS'][0]) === 'PRIVATE' ? 'private' : 'public'; + } $rrule = (string) ($props['RRULE'][0] ?? ''); if ($rrule !== '') { @@ -97,7 +132,7 @@ final class IcsService return $payload; } - private function eventToLines(array $event, array $deletedKeys): array + private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array { $uid = (string) ($event['uid'] ?? ''); $uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin'); @@ -109,18 +144,26 @@ final class IcsService } $allDay = (bool) ($event['all_day_event'] ?? false); + $visibility = strtolower(trim((string) ($event['visibility'] ?? 'public'))) === 'private' ? 'private' : 'public'; + $isRedactedPrivate = $redactPrivate && $visibility === 'private'; $updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC')); $lines = [ 'BEGIN:VEVENT', 'UID:' . $this->escapeText($uid), - 'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')), - 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')), - 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')), - 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')), + 'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')), 'DTSTAMP:' . $this->toUtcIcs($updated), 'LAST-MODIFIED:' . $this->toUtcIcs($updated), ]; + if (!$isRedactedPrivate) { + $icsVisibility = strtoupper($visibility === 'private' ? 'PRIVATE' : 'PUBLIC'); + $lines[] = 'CLASS:' . $icsVisibility; + // Compatibility hint for clients that rely on CalendarServer-style access fields. + $lines[] = 'X-CALENDARSERVER-ACCESS:' . $icsVisibility; + $lines[] = 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')); + $lines[] = 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')); + $lines[] = 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')); + } if ($allDay) { $lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd'); diff --git a/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php b/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php index 71fe24e..0fd8bd8 100644 --- a/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php +++ b/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php @@ -148,6 +148,7 @@ final class RecurrenceExpander return [ 'event_id' => (int) ($event['id'] ?? 0), 'uid' => (string) ($event['uid'] ?? ''), + 'visibility' => (string) ($event['visibility'] ?? 'public'), 'title' => (string) ($event['title'] ?? ''), 'description' => (string) ($event['description'] ?? ''), 'location' => (string) ($event['location'] ?? ''), diff --git a/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php b/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php index cb2281c..6537806 100644 --- a/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php +++ b/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php @@ -10,7 +10,7 @@ use DateTimeZone; final class MigrationManager { - private const SCHEMA_VERSION = '3'; + private const SCHEMA_VERSION = '5'; private const STEM_OPTION = 'calendar_plugin_table_stem'; public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar') @@ -31,10 +31,12 @@ final class MigrationManager $users = $prefix . $stem . '_users'; $tokens = $prefix . $stem . '_user_tokens'; $audit = $prefix . $stem . '_audit_log'; + $tombstones = $prefix . $stem . '_caldav_tombstones'; $sqlEvents = "CREATE TABLE {$events} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, uid VARCHAR(191) NOT NULL, + visibility VARCHAR(16) NOT NULL DEFAULT 'public', title TEXT NOT NULL, description LONGTEXT NOT NULL, location TEXT NOT NULL, @@ -115,11 +117,18 @@ final class MigrationManager KEY created_at (created_at) ) {$charsetCollate};"; + $sqlTombstones = "CREATE TABLE {$tombstones} ( + resource VARCHAR(191) NOT NULL, + deleted_at VARCHAR(32) NOT NULL, + PRIMARY KEY (resource) + ) {$charsetCollate};"; + dbDelta($sqlEvents); dbDelta($sqlExceptions); dbDelta($sqlUsers); dbDelta($sqlTokens); dbDelta($sqlAudit); + dbDelta($sqlTombstones); // Ensure every event has a stable CalDAV object resource name. $this->db->query( @@ -129,6 +138,13 @@ final class MigrationManager AND uid IS NOT NULL AND uid <> ''" ); + $this->db->query( + "UPDATE {$events} + SET visibility = 'public' + WHERE visibility IS NULL + OR visibility = '' + OR visibility NOT IN ('public', 'private')" + ); $this->normalizeEventDateTimesToLondon($events); update_option(self::STEM_OPTION, $stem); @@ -170,6 +186,7 @@ final class MigrationManager $prefix . $stem . '_users', $prefix . $stem . '_user_tokens', $prefix . $stem . '_audit_log', + $prefix . $stem . '_caldav_tombstones', ]; foreach ($tables as $table) { $sql = $this->db->prepare('SHOW TABLES LIKE %s', $table); diff --git a/package/staging/calendar-plugin/src/Plugin.php b/package/staging/calendar-plugin/src/Plugin.php index 42c77db..8b573ea 100644 --- a/package/staging/calendar-plugin/src/Plugin.php +++ b/package/staging/calendar-plugin/src/Plugin.php @@ -209,6 +209,7 @@ final class Plugin
+
@@ -256,6 +257,7 @@ final class Plugin

Event Details

+
@@ -409,6 +411,7 @@ final class Plugin const clearEditor=()=>{ s("cp-event-id").value=""; ["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value=""); + s("cp-visibility").value="public"; s("cp-occurrence-key-iso").value=""; s("cp-all-day").checked=false; s("cp-repeat-type").value="none"; @@ -452,6 +455,7 @@ final class Plugin const openDetailsForItem=(it)=>{ s("cp-details-title").value=it.title||""; + s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public"; s("cp-details-category").value=it.category||""; s("cp-details-location").value=it.location||""; s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event); @@ -478,6 +482,7 @@ final class Plugin s("cp-editor-title").textContent="Edit Event"; s("cp-event-id").value=itemId(source); s("cp-title").value=source.title||""; + s("cp-visibility").value=source.visibility==="private"?"private":"public"; s("cp-description").value=source.description||""; s("cp-location").value=source.location||""; s("cp-category").value=source.category||""; @@ -532,6 +537,7 @@ final class Plugin ok:true, payload:{ title:title, + visibility:s("cp-visibility").value==="private"?"private":"public", description:s("cp-description").value, location:s("cp-location").value, category:s("cp-category").value, @@ -1592,10 +1598,16 @@ HTML $date = (string) ($request->get_param('date') ?: gmdate('Y-m-d')); $futureOnlyRaw = (string) ($request->get_param('future_only') ?? ''); $futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true); - $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly); + $includePrivateDetails = $this->canWriteCalendar($request); + $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails); return [ 'data' => $items, - 'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly], + 'meta' => [ + 'count' => count($items), + 'view' => $view, + 'future_only' => $futureOnly, + 'redacted_private' => !$includePrivateDetails, + ], ]; }, ] @@ -1608,7 +1620,7 @@ HTML 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => function (): array { - $items = $this->eventService->listSidebarUpcoming(14); + $items = $this->eventService->listSidebarUpcoming(14, true); return [ 'data' => $items, 'meta' => ['count' => count($items), 'window_days' => 14], @@ -1840,13 +1852,19 @@ HTML [ 'methods' => 'GET', 'permission_callback' => '__return_true', - 'callback' => function (): array { + 'callback' => function ($request): array|\WP_Error { $settings = $this->settingsService->getAll(); + $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read'); + $includePrivateDetails = $this->canWriteCalendar($request); + if ($icsMode === 'authenticated_read' && !$includePrivateDetails) { + return $this->error('auth_required', 'authentication required', 401); + } $calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar'); $ics = $this->icsService->buildCalendar( $this->eventService->listEvents(), fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId), - $calendarName + $calendarName, + !$includePrivateDetails ); return ['data' => $ics]; }, @@ -1964,9 +1982,11 @@ HTML private function serveIcsResponse(): void { $settings = $this->settingsService->getAll(); + $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read'); + $includePrivateDetails = $this->canWriteCalendar(null); if ( - (string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read' - && $this->resolveCalDavUserForRequest(null) === null + $icsMode === 'authenticated_read' + && !$includePrivateDetails ) { http_response_code(401); header('Content-Type: application/json; charset=utf-8'); @@ -1978,7 +1998,8 @@ HTML $ics = $this->icsService->buildCalendar( $this->eventService->listEvents(), fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId), - $calendarName + $calendarName, + !$includePrivateDetails ); $etag = '"' . substr(sha1($ics), 0, 16) . '"'; $lastModified = gmdate('D, d M Y H:i:s') . ' GMT'; @@ -2010,7 +2031,18 @@ HTML $resourcePrefix = $collection; if ($method === 'HEAD') { - if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) { + if ( + $path === $root + || $path === $root . '/' + || $path === $principalCollection + || $path === rtrim($principalCollection, '/') + || $path === $principal + || $path === rtrim($principal, '/') + || $path === $calendarsRoot + || $path === rtrim($calendarsRoot, '/') + || $path === $collection + || $path === rtrim($collection, '/') + ) { header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD'); header('DAV: 1, calendar-access'); http_response_code(200); @@ -2030,6 +2062,26 @@ HTML } } + if ($method === 'GET') { + if ( + $path === $root + || $path === $root . '/' + || $path === $principalCollection + || $path === rtrim($principalCollection, '/') + || $path === $principal + || $path === rtrim($principal, '/') + || $path === $calendarsRoot + || $path === rtrim($calendarsRoot, '/') + || $path === $collection + || $path === rtrim($collection, '/') + ) { + header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD'); + header('DAV: 1, calendar-access'); + http_response_code(200); + return; + } + } + if ($method === 'OPTIONS') { header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD'); header('DAV: 1, calendar-access'); @@ -2079,7 +2131,7 @@ HTML return; } - if ($method === 'REPORT' && $path === $collection) { + if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) { $body = (string) file_get_contents('php://input'); header('Content-Type: application/xml; charset=utf-8'); http_response_code(207); @@ -2269,7 +2321,15 @@ HTML $items = []; if (str_contains($bodyLower, 'sync-collection')) { - $items = $this->calDavService->multiget($resources); + $clientSyncToken = $this->extractSyncCollectionToken($xmlBody); + // If client token is already current, no changes should be emitted. + if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) { + $items = []; + } else { + // Fallback implementation: emit current objects for initial/out-of-date tokens. + // Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures. + $items = $this->calDavService->multiget($resources); + } } elseif (str_contains($bodyLower, 'calendar-query')) { $items = $this->calDavService->multiget($resources); if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) { @@ -2324,6 +2384,9 @@ HTML foreach ($rows as $row) { $seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';'; } + foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) { + $seed .= 'deleted:' . $deletedResource . ';'; + } return 'urn:calendar-plugin:sync:' . sha1($seed); } @@ -2353,6 +2416,15 @@ HTML return $this->icalToIso((string) $m[1]); } + private function extractSyncCollectionToken(string $xmlBody): string + { + if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) { + return ''; + } + $token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8')); + return $token; + } + private function unwrapServiceResult(array $result): array|\WP_Error { if (isset($result['error']) && is_array($result['error'])) { diff --git a/requirements/caldav.md b/requirements/caldav.md index 9284547..c5d7b5d 100644 --- a/requirements/caldav.md +++ b/requirements/caldav.md @@ -70,6 +70,7 @@ The endpoint must support these operations at minimum. - `REPORT` (`calendar-query`): return events in collection, including time-range filtering. - `REPORT` (`calendar-multiget`): fetch specific event resources by href. - `GET`: retrieve individual event resource (`text/calendar`). +- `GET` on CalDAV collection resources should return `200` (empty body acceptable) for client availability probes. ### Create and Update - `PUT`: create new event resource or replace an existing event resource. @@ -86,6 +87,8 @@ The endpoint must support these operations at minimum. - `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates. - `REPORT` (`sync-collection`) should be supported for incremental sync tokens. - Sync token invalidation/rotation behavior must be deterministic and documented. +- `sync-collection` no-change requests (client token equals current token) should return an empty change set (`207` with no changed/deleted `response` entries). +- `sync-collection` should prefer stable incremental behavior over historical replay; unchanged resyncs must not emit large historical tombstone sets. ## iCalendar Representation Requirements CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components. diff --git a/requirements/caldav_endpoints.md b/requirements/caldav_endpoints.md index f89084a..f11518b 100644 --- a/requirements/caldav_endpoints.md +++ b/requirements/caldav_endpoints.md @@ -53,6 +53,7 @@ URI rules: ### Calendar collection resources - `OPTIONS` - `PROPFIND` +- `GET` (availability probe support; returns `200` with empty body on collection URL) - `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`) ### Event object resources @@ -78,6 +79,9 @@ Calendar collection and principal responses must support, at minimum, these prop - `calendar-query` with time-range filtering - `calendar-multiget` by href set - `sync-collection` for incremental changes since sync token +- Collection `REPORT` handling must accept both canonical and non-canonical trailing-slash variants (for example `/caldav/calendars/public/` and `/caldav/calendars/public`). +- For `sync-collection`, if client sync-token equals server sync-token, server should return `207` with no `D:response` change entries. +- `sync-collection` responses must not emit large sets of historical `404` tombstones for unchanged state. If a report is unsupported for a resource, server returns standards-appropriate error status with DAV error body. @@ -115,4 +119,7 @@ Acceptance should verify: - URI layout and discovery flows are stable. - Required methods return expected statuses. - REPORT responses include correct event sets. +- Collection `GET` returns `200` for authenticated probe requests. +- `sync-collection` no-change request (current token) returns `207` with zero change responses. +- `sync-collection` works with and without trailing slash on collection URI. - Conditional write and etag behavior prevents stale overwrite. diff --git a/requirements/test_strategy.md b/requirements/test_strategy.md index e0c04fe..89bd207 100644 --- a/requirements/test_strategy.md +++ b/requirements/test_strategy.md @@ -68,6 +68,10 @@ Must run: - Recurrence exception behavior (single occurrence delete without split) is mandatory coverage. - Authn/Authz paths for admin, API, and CalDAV roles are mandatory coverage. - Error-model contract coverage is mandatory for API endpoints. +- CalDAV client-compatibility regressions are mandatory coverage, including: + - collection `GET` availability probe compatibility + - collection `REPORT` handling with and without trailing slash + - `sync-collection` no-change stability (current token -> zero change entries) ## Pass/Fail Gates - Any required suite failure blocks merge/release as applicable. diff --git a/scripts/deploy_remote.sh b/scripts/deploy_remote.sh index 58aac1f..5205be1 100755 --- a/scripts/deploy_remote.sh +++ b/scripts/deploy_remote.sh @@ -105,10 +105,12 @@ fi "${SSH[@]}" "set -euo pipefail; rm -rf '${STAGE_DIR}/extracted'; mkdir -p '${STAGE_DIR}/extracted'; unzip -q '${STAGE_DIR}/${artifact_base}' -d '${STAGE_DIR}/extracted'; test -f '${STAGE_DIR}/extracted/calendar-plugin/calendar-plugin.php'" "${SSH[@]}" "set -euo pipefail; rsync -a --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/'" "${SSH[@]}" "set -euo pipefail; chown -R www-data:www-data '${REMOTE_APP_DIR}'" -"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null 2>&1 || true" +# Always cycle plugin activation so activation-hook migrations run on every deploy. +"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin deactivate calendar-plugin --allow-root >/dev/null 2>&1 || true; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null" -# Exact-match style checksum dry-run check -"${SSH[@]}" "set -euo pipefail; rsync -avznc --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^[^./]|^\./' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi" +# Exact-match style checksum dry-run check for content drift. +# Ignore directory metadata-only differences, which are expected after chown/remote extraction. +"${SSH[@]}" "set -euo pipefail; rsync -rcn --delete --omit-dir-times --no-perms --no-owner --no-group --itemize-changes '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^(>f|\\*deleting|cd|cL|cD|cS)' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi" # Ownership sanity check: must be zero mismatches non_owned="$("${SSH[@]}" "set -euo pipefail; find '${REMOTE_APP_DIR}' \( ! -user www-data -o ! -group www-data \) | wc -l")" diff --git a/tests/api_test_cases.md b/tests/api_test_cases.md index 03a977b..4f93ef4 100644 --- a/tests/api_test_cases.md +++ b/tests/api_test_cases.md @@ -142,6 +142,31 @@ Adjust paths to actual implementation while preserving case coverage. - CalDAV link uses `//caldav/`. - Link path changes match configured slug value. +## CalDAV Sync Compatibility Tests + +### API-CALDAV-001 Collection URL Variant Compatibility +- Method: `REPORT sync-collection` against both: + - `.../caldav/calendars/public/` + - `.../caldav/calendars/public` +- Assertions: + - Both return `207`. + - Neither returns method errors due to trailing slash variant. + +### API-CALDAV-002 Collection Availability Probe +- Method: `GET .../caldav/calendars/public/` (authenticated) +- Assertions: + - Returns `200` (body may be empty). + - Does not force client into temporary unavailable state on probe. + +### API-CALDAV-003 No-Change Incremental Sync Stability +- Method: + 1. run `REPORT sync-collection` to obtain sync token + 2. rerun `REPORT sync-collection` with returned token and no intervening changes +- Assertions: + - Returns `207` + - Contains zero `` change entries + - Does not emit historical `404` tombstone floods for unchanged state + ## Negative and Security Tests ### API-SEC-001 Unauthorized Access diff --git a/tests/run_remote_tests.sh b/tests/run_remote_tests.sh index b9a7ad6..381b237 100755 --- a/tests/run_remote_tests.sh +++ b/tests/run_remote_tests.sh @@ -12,8 +12,9 @@ fi BASE_URL="${BASE_URL:-${WP_URL:-}}" AUTH_USER="${CAL_TEST_USER:-adrians@chezstephens.org.uk}" AUTH_PASS="${CAL_TEST_PASSWORD:-brillig1}" -EXPECTED_TABLE_PREFIX="${CAL_TABLE_PREFIX_EXPECTED:-wp_cs_calendar}" ENABLE_PREFIX_CHECK="${CAL_ENABLE_PREFIX_CHECK:-1}" +CAL_URL_SLUG="${CAL_URL_SLUG:-}" +CAL_CALDAV_PATH="${CAL_CALDAV_PATH:-}" REMOTE_WP_PATH="${REMOTE_WP_PATH:-}" if [[ -z "${REMOTE_WP_PATH}" ]] && [[ -n "${REMOTE_APP_DIR:-}" ]]; then REMOTE_WP_PATH="$(dirname "$(dirname "${REMOTE_APP_DIR}")")" @@ -24,6 +25,10 @@ fi FAILURES=0 CREATED_EVENT_ID="" CREATED_REC_EVENT_ID="" +CREATED_PRIVATE_EVENT_ID="" +CREATED_SYNC_DELETE_EVENT_ID="" +DETECTED_URL_SLUG="" +SSH_OK=0 usage() { cat <<'TXT' @@ -36,8 +41,9 @@ Env overrides: BASE_URL CAL_TEST_USER CAL_TEST_PASSWORD - CAL_TABLE_PREFIX_EXPECTED CAL_ENABLE_PREFIX_CHECK + CAL_URL_SLUG + CAL_CALDAV_PATH Defaults: BASE_URL -> credentials/.env:WP_URL @@ -110,6 +116,14 @@ cleanup() { curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \ "${BASE_URL}/wp-json/calendar/v1/events/${CREATED_REC_EVENT_ID}" >/dev/null || true fi + if [[ -n "${CREATED_PRIVATE_EVENT_ID}" ]]; then + curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \ + "${BASE_URL}/wp-json/calendar/v1/events/${CREATED_PRIVATE_EVENT_ID}" >/dev/null || true + fi + if [[ -n "${CREATED_SYNC_DELETE_EVENT_ID}" ]]; then + curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \ + "${BASE_URL}/wp-json/calendar/v1/events/${CREATED_SYNC_DELETE_EVENT_ID}" >/dev/null || true + fi } trap cleanup EXIT @@ -122,7 +136,11 @@ else record_fail "health endpoint unreachable" fi -if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then +if [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then + SSH_OK=1 +fi + +if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ "${SSH_OK}" == "1" ]]; then step "table prefix configuration" SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}" if [[ "${SSH_KEY_PATH}" != /* ]]; then @@ -131,16 +149,21 @@ if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -n "${REMOTE_HOST:-}" ]] && [[ -n SSH_PREFIX=(ssh -F /dev/null -i "${SSH_KEY_PATH}" -p "${REMOTE_PORT}" -o StrictHostKeyChecking=accept-new "${REMOTE_USER}@${REMOTE_HOST}") if ! "${SSH_PREFIX[@]}" "echo ok" >/dev/null 2>&1; then echo "[remote-tests] WARN: SSH unavailable, skipping table prefix check" + SSH_OK=0 else + DETECTED_URL_SLUG="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_url_slug --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)" STEM="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_table_stem --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)" - if [[ -n "${STEM}" ]] && [[ "${STEM}" != "cs_calendar" ]] && [[ "${STEM}" != "calendar" ]]; then - record_fail "table stem option expected cs_calendar/calendar got '${STEM}'" - fi + WP_DB_PREFIX="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} eval 'global \$wpdb; echo \$wpdb->prefix;' --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)" TABLE_LIST="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} db query \"SHOW TABLES;\" --allow-root --silent --skip-column-names 2>/dev/null || true" | tr -d '\r')" - TABLE_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "${EXPECTED_TABLE_PREFIX}_events" | head -n1 || true)" - LEGACY_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "wp_calendar_events" | head -n1 || true)" - if [[ "${TABLE_EXISTS}" != "${EXPECTED_TABLE_PREFIX}_events" ]] && [[ "${LEGACY_EXISTS}" != "wp_calendar_events" ]]; then - record_fail "expected table prefix '${EXPECTED_TABLE_PREFIX}' (or legacy wp_calendar) not found" + if [[ -n "${STEM}" ]] && [[ -n "${WP_DB_PREFIX}" ]]; then + EXPECTED_EVENTS_TABLE="${WP_DB_PREFIX}${STEM}_events" + if ! printf '%s\n' "${TABLE_LIST}" | grep -Fxq "${EXPECTED_EVENTS_TABLE}"; then + record_fail "table stem '${STEM}' does not match an existing events table (${EXPECTED_EVENTS_TABLE})" + fi + else + if ! printf '%s\n' "${TABLE_LIST}" | grep -Eq '_calendar_.*_events$|_calendar_events$'; then + record_fail "no calendar events table detected from SHOW TABLES output" + fi fi fi fi @@ -267,19 +290,217 @@ PY fi fi +step "public privacy redaction" +PRIV_UID="remote-private-redact-$(date +%s)@calendar-plugin" +PRIV_JSON=$(cat <${PRE_DELETE_TOKEN}1" + SYNC_REPORT_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \ + --data "${SYNC_REPORT_BODY}" -o /tmp/remote_test_sync_report.xml -w '%{http_code}' \ + "${CALDAV_COLLECTION_URL}" || true)" + if [[ "${SYNC_REPORT_HTTP}" != "207" ]]; then + record_fail "sync-collection report failed (http ${SYNC_REPORT_HTTP})" + else + SYNC_REPORT_HTTP_NOSLASH="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \ + --data "${SYNC_REPORT_BODY}" -o /tmp/remote_test_sync_report_noslash.xml -w '%{http_code}' \ + "${CALDAV_COLLECTION_URL%/}" || true)" + if [[ "${SYNC_REPORT_HTTP_NOSLASH}" != "207" ]]; then + record_fail "sync-collection report without trailing slash failed (http ${SYNC_REPORT_HTTP_NOSLASH})" + fi + if grep -Fq 'HTTP/1.1 404 Not Found' /tmp/remote_test_sync_report.xml; then + record_fail "sync-collection returned 404 entries for incremental sync" + fi + SYNC_TOKEN_1="$(python3 - <<'PY' +import re +xml=open('/tmp/remote_test_sync_report.xml', encoding='utf-8', errors='ignore').read() +m=re.search(r'(.*?)', xml, re.S) +print((m.group(1).strip() if m else '')) +PY +)" + if [[ -z "${SYNC_TOKEN_1}" ]]; then + record_fail "incremental sync-collection missing sync-token" + else + SYNC_REPORT_BODY_NOCHANGE="${SYNC_TOKEN_1}1" + SYNC_REPORT_NOCHANGE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \ + --data "${SYNC_REPORT_BODY_NOCHANGE}" -o /tmp/remote_test_sync_report_nochange.xml -w '%{http_code}' \ + "${CALDAV_COLLECTION_URL}" || true)" + if [[ "${SYNC_REPORT_NOCHANGE_HTTP}" != "207" ]]; then + record_fail "no-change sync-collection report failed (http ${SYNC_REPORT_NOCHANGE_HTTP})" + else + RESPONSE_COUNT_NOCHANGE="$(python3 - <<'PY' +import re +xml=open('/tmp/remote_test_sync_report_nochange.xml', encoding='utf-8', errors='ignore').read() +print(len(re.findall(r'', xml))) +PY +)" + if [[ "${RESPONSE_COUNT_NOCHANGE}" != "0" ]]; then + record_fail "no-change sync-collection should return 0 responses, got ${RESPONSE_COUNT_NOCHANGE}" + fi + fi + fi + fi + fi + fi +fi printf '\n[remote-tests] completed with %d failure(s)\n' "${FAILURES}" if [[ "${FAILURES}" -gt 0 ]]; then diff --git a/tests/smoke_tests.md b/tests/smoke_tests.md index 7905f33..a45ff25 100644 --- a/tests/smoke_tests.md +++ b/tests/smoke_tests.md @@ -68,6 +68,9 @@ Use a minimal subset: - discoverable calendar collection href - Authenticated `PROPFIND` on principal must return `calendar-home-set`. - Authenticated `PROPFIND /caldav/calendars/` must include calendar collection metadata (``) and supported component set (`VEVENT`). +- Authenticated `GET` on calendar collection (`.../calendars/public/`) must return `200` (availability probe compatibility). +- Authenticated `REPORT sync-collection` must succeed for both trailing slash and no-trailing-slash collection URLs. +- No-change `sync-collection` (current token) must return no change entries. - Legacy local harness check is archived at `fixture-tests/fixture_caldav_client_compat_smoke.sh`. ### SMK-010 Lifecycle Controls (Staging Only)