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 1e69edd..d501ca0 100644 Binary files a/package/calendar-plugin-0.1.15.zip and b/package/calendar-plugin-0.1.15.zip differ 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)