diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 6716b6c5ac1cd..c010a3e98d602 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -77,6 +77,7 @@ 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php', + 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarObject.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php', 'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => $baseDir . '/../lib/CalDAV/Federation/FederationSharingService.php', 'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => $baseDir . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 5b5b1e3fcb413..bdb965aab3ee0 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -92,6 +92,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php', + 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarObject.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php', 'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederationSharingService.php', 'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php', diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index e53fe0369c80d..87a8e99c3d1f5 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -43,9 +43,9 @@ public function getCalendars(string $principalUri, array $calendarUris = []): ar }); } - $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos); $iCalendars = []; + $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos); foreach ($calendarInfos as $calendarInfo) { $user = str_replace('principals/users/', '', $calendarInfo['principaluri']); $path = 'calendars/' . $user . '/' . $calendarInfo['uri']; @@ -60,14 +60,12 @@ public function getCalendars(string $principalUri, array $calendarUris = []): ar ); } - $additionalFederatedProps = $this->getAdditionalPropertiesForCalendars( - $federatedCalendarInfos, - ); + $additionalFederatedProps = $this->getAdditionalPropertiesForCalendars($federatedCalendarInfos); foreach ($federatedCalendarInfos as $calendarInfo) { $user = str_replace('principals/users/', '', $calendarInfo['principaluri']); $path = 'calendars/' . $user . '/' . $calendarInfo['uri']; if (isset($additionalFederatedProps[$path])) { - $calendarInfo = array_merge($calendarInfo, $additionalProperties[$path]); + $calendarInfo = array_merge($calendarInfo, $additionalFederatedProps[$path]); } $iCalendars[] = new FederatedCalendarImpl($calendarInfo, $this->calDavBackend); diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php index 05d9ea9d6ecfd..8eab255b9ef3c 100644 --- a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php +++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php @@ -104,9 +104,10 @@ public function shareReceived(ICloudFederationShare $share): string { ); } - // TODO: implement read-write sharing + // convert access to permissions $permissions = match ($access) { DavSharingBackend::ACCESS_READ => Constants::PERMISSION_READ, + DavSharingBackend::ACCESS_READ_WRITE => Constants::PERMISSION_READ | Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE, default => throw new ProviderCouldNotAddShareException( "Unsupported access value: $access", '', @@ -122,20 +123,27 @@ public function shareReceived(ICloudFederationShare $share): string { $sharedWithPrincipal = 'principals/users/' . $share->getShareWith(); // Delete existing incoming federated share first - $this->federatedCalendarMapper->deleteByUri($sharedWithPrincipal, $calendarUri); - - $calendar = new FederatedCalendarEntity(); - $calendar->setPrincipaluri($sharedWithPrincipal); - $calendar->setUri($calendarUri); - $calendar->setRemoteUrl($calendarUrl); - $calendar->setDisplayName($displayName); - $calendar->setColor($color); - $calendar->setToken($share->getShareSecret()); - $calendar->setSharedBy($share->getSharedBy()); - $calendar->setSharedByDisplayName($share->getSharedByDisplayName()); - $calendar->setPermissions($permissions); - $calendar->setComponents($components); - $calendar = $this->federatedCalendarMapper->insert($calendar); + $calendar = $this->federatedCalendarMapper->findByUri($sharedWithPrincipal, $calendarUri); + + if ($calendar === null) { + $calendar = new FederatedCalendarEntity(); + $calendar->setPrincipaluri($sharedWithPrincipal); + $calendar->setUri($calendarUri); + $calendar->setRemoteUrl($calendarUrl); + $calendar->setDisplayName($displayName); + $calendar->setColor($color); + $calendar->setToken($share->getShareSecret()); + $calendar->setSharedBy($share->getSharedBy()); + $calendar->setSharedByDisplayName($share->getSharedByDisplayName()); + $calendar->setPermissions($permissions); + $calendar->setComponents($components); + $calendar = $this->federatedCalendarMapper->insert($calendar); + } else { + $calendar->setToken($share->getShareSecret()); + $calendar->setPermissions($permissions); + $calendar->setComponents($components); + $this->federatedCalendarMapper->update($calendar); + } $this->jobList->add(FederatedCalendarSyncJob::class, [ FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(), diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php index 34367e0e88efd..b3df30f547012 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php @@ -10,29 +10,247 @@ namespace OCA\DAV\CalDAV\Federation; use OCA\DAV\CalDAV\CalDavBackend; -use OCA\DAV\CalDAV\Calendar; -use OCP\IConfig; -use OCP\IL10N; -use Psr\Log\LoggerInterface; -use Sabre\CalDAV\Backend; +use OCP\Constants; +use Sabre\CalDAV\ICalendar; +use Sabre\CalDAV\Plugin; +use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; +use Sabre\DAV\Exception\MethodNotAllowed; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\IMultiGet; +use Sabre\DAV\IProperties; +use Sabre\DAV\PropPatch; + +class FederatedCalendar implements ICalendar, IProperties, IMultiGet { + + private const CALENDAR_TYPE = CalDavBackend::CALENDAR_TYPE_FEDERATED; + private const DAV_PROPERTY_CALENDAR_LABEL = '{DAV:}displayname'; + private const DAV_PROPERTY_CALENDAR_COLOR = '{http://apple.com/ns/ical/}calendar-color'; + + private string $principalUri; + private string $calendarUri; + private ?array $calendarACL = null; + private FederatedCalendarEntity $federationInfo; -class FederatedCalendar extends Calendar { public function __construct( - Backend\BackendInterface $caldavBackend, - $calendarInfo, - IL10N $l10n, - IConfig $config, - LoggerInterface $logger, private readonly FederatedCalendarMapper $federatedCalendarMapper, + private readonly FederatedCalendarSyncService $federatedCalendarService, + private readonly CalDavBackend $caldavBackend, + $calendarInfo, ) { - parent::__construct($caldavBackend, $calendarInfo, $l10n, $config, $logger); + $this->principalUri = $calendarInfo['principaluri']; + $this->calendarUri = $calendarInfo['uri']; + $this->federationInfo = $federatedCalendarMapper->findByUri($this->principalUri, $this->calendarUri); + } + + public function getResourceId(): int { + return $this->federationInfo->getId(); + } + + public function getName() { + return $this->federationInfo->getUri(); + } + + public function setName($name) { + throw new MethodNotAllowed('Renaming federated calendars is not allowed'); + } + + protected function getCalendarType(): int { + return self::CALENDAR_TYPE; + } + + public function getPrincipalURI() { + return $this->federationInfo->getPrincipaluri(); + } + + public function getOwner() { + return $this->federationInfo->getSharedByPrincipal(); + } + + public function getGroup() { + return null; + } + + public function getACL() { + + if ($this->calendarACL !== null) { + return $this->calendarACL; + } + + $permissions = $this->federationInfo->getPermissions(); + // default permission + $acl = [ + // read object permission + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->principalUri, + 'protected' => true, + ], + // read acl permission + [ + 'privilege' => '{DAV:}read-acl', + 'principal' => $this->principalUri, + 'protected' => true, + ], + // write properties permission (calendar name, color) + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->principalUri, + 'protected' => true, + ], + ]; + // create permission + if ($permissions & Constants::PERMISSION_CREATE) { + $acl[] = [ + 'privilege' => '{DAV:}bind', + 'principal' => $this->principalUri, + 'protected' => true, + ]; + } + // update permission + if ($permissions & Constants::PERMISSION_UPDATE) { + $acl[] = [ + 'privilege' => '{DAV:}write-content', + 'principal' => $this->principalUri, + 'protected' => true, + ]; + } + // delete permission + if ($permissions & Constants::PERMISSION_DELETE) { + $acl[] = [ + 'privilege' => '{DAV:}unbind', + 'principal' => $this->principalUri, + 'protected' => true, + ]; + } + + // cache the calculated ACL for later use + $this->calendarACL = $acl; + + return $acl; + } + + public function setACL(array $acl) { + throw new MethodNotAllowed('Changing ACLs on federated calendars is not allowed'); + } + + public function getSupportedPrivilegeSet(): ?array { + return null; + } + + public function getProperties($properties): array { + return [ + self::DAV_PROPERTY_CALENDAR_LABEL => $this->federationInfo->getDisplayName(), + self::DAV_PROPERTY_CALENDAR_COLOR => $this->federationInfo->getColor(), + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(explode(',', $this->federationInfo->getComponents())), + ]; + } + + public function propPatch(PropPatch $propPatch): void { + $mutations = $propPatch->getMutations(); + if (count($mutations) > 0) { + // evaluate if name was changed + if (isset($mutations[self::DAV_PROPERTY_CALENDAR_LABEL])) { + $this->federationInfo->setDisplayName($mutations[self::DAV_PROPERTY_CALENDAR_LABEL]); + $propPatch->setResultCode(self::DAV_PROPERTY_CALENDAR_LABEL, 200); + } + // evaluate if color was changed + if (isset($mutations[self::DAV_PROPERTY_CALENDAR_COLOR])) { + $this->federationInfo->setColor($mutations[self::DAV_PROPERTY_CALENDAR_COLOR]); + $propPatch->setResultCode(self::DAV_PROPERTY_CALENDAR_COLOR, 200); + } + $this->federatedCalendarMapper->update($this->federationInfo); + } + } + + + public function getChildACL() { + return $this->getACL(); + } + + public function getLastModified() { + return $this->federationInfo->getLastSync(); } public function delete() { $this->federatedCalendarMapper->deleteById($this->getResourceId()); } - protected function getCalendarType(): int { - return CalDavBackend::CALENDAR_TYPE_FEDERATED; + public function createDirectory($name) { + throw new MethodNotAllowed('Creating nested collection is not allowed'); + } + + public function calendarQuery(array $filters) { + $uris = $this->caldavBackend->calendarQuery($this->federationInfo->getId(), $filters, $this->getCalendarType()); + return $uris; + } + + public function getChild($name) { + $obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType()); + + if ($obj === null) { + throw new NotFound('Calendar object not found'); + } + + return new FederatedCalendarObject($this, $obj); + } + + public function getChildren() { + $objs = $this->caldavBackend->getCalendarObjects($this->federationInfo->getId(), $this->getCalendarType()); + + $children = []; + foreach ($objs as $obj) { + $children[] = new FederatedCalendarObject($this, $obj); + } + + return $children; + } + + public function getMultipleChildren(array $paths) { + $objs = $this->caldavBackend->getMultipleCalendarObjects($this->federationInfo->getId(), $paths, $this->getCalendarType()); + + $children = []; + foreach ($objs as $obj) { + $children[] = new FederatedCalendarObject($this, $obj); + } + + return $children; + } + + public function childExists($name) { + $obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType()); + return $obj !== null; + } + + public function createFile($name, $data = null) { + if (is_resource($data)) { + $data = stream_get_contents($data); + } + + // Create on remote server first + $etag = $this->federatedCalendarService->createCalendarObject($this->federationInfo, $name, $data); + + // Then store locally + return $this->caldavBackend->createCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType()); } + + public function updateFile($name, $data = null) { + if (is_resource($data)) { + $data = stream_get_contents($data); + } + + // Update remote calendar first + $etag = $this->federatedCalendarService->updateCalendarObject($this->federationInfo, $name, $data); + + // Then update locally + return $this->caldavBackend->updateCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType()); + } + + public function deleteFile($name) { + // Delete from remote server first + $this->federatedCalendarService->deleteCalendarObject($this->federationInfo, $name); + + // Then delete locally + return $this->caldavBackend->deleteCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType()); + } + } diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php index 91027ef0d7bac..e6d869fec88b7 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php @@ -94,8 +94,8 @@ public function toCalendarInfo(): array { '{' . \Sabre\CalDAV\Plugin::NS_CALENDARSERVER . '}getctag' => $this->getSyncTokenForSabre(), '{' . \Sabre\CalDAV\Plugin::NS_CALDAV . '}supported-calendar-component-set' => $this->getSupportedCalendarComponentSet(), '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->getSharedByPrincipal(), - // TODO: implement read-write sharing - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => 1 + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => ($this->getPermissions() & \OCP\Constants::PERMISSION_UPDATE) === 0 ? 1 : 0, + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}permissions' => $this->getPermissions(), ]; } } diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php index df7af2a6ad53d..89a544d79389f 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php @@ -9,34 +9,23 @@ namespace OCA\DAV\CalDAV\Federation; -use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\CalDavBackend; -use OCP\IConfig; -use OCP\IL10N; -use OCP\L10N\IFactory as IL10NFactory; -use Psr\Log\LoggerInterface; class FederatedCalendarFactory { - private readonly IL10N $l10n; public function __construct( - private readonly CalDavBackend $caldavBackend, - private readonly IConfig $config, - private readonly LoggerInterface $logger, private readonly FederatedCalendarMapper $federatedCalendarMapper, - IL10NFactory $l10nFactory, + private readonly FederatedCalendarSyncService $federatedCalendarService, + private readonly CalDavBackend $caldavBackend, ) { - $this->l10n = $l10nFactory->get(Application::APP_ID); } public function createFederatedCalendar(array $calendarInfo): FederatedCalendar { return new FederatedCalendar( + $this->federatedCalendarMapper, + $this->federatedCalendarService, $this->caldavBackend, $calendarInfo, - $this->l10n, - $this->config, - $this->logger, - $this->federatedCalendarMapper, ); } } diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php index 59cece7e8c88a..23db57f1f0839 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php @@ -51,8 +51,7 @@ public function search(string $pattern, array $searchProperties = [], array $opt } public function getPermissions(): int { - // TODO: implement read-write sharing - return Constants::PERMISSION_READ; + return $this->calendarInfo['{http://owncloud.org/ns}permissions'] ?? Constants::PERMISSION_READ; } public function isDeleted(): bool { @@ -64,7 +63,8 @@ public function isShared(): bool { } public function isWritable(): bool { - return false; + $permissions = $this->getPermissions(); + return ($permissions & Constants::PERMISSION_UPDATE) !== 0; } public function isEnabled(): bool { diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarObject.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarObject.php new file mode 100644 index 0000000000000..c2af313e92201 --- /dev/null +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarObject.php @@ -0,0 +1,130 @@ +objectData['uri']; + } + + public function setName($name) { + throw new \Exception('Not implemented'); + } + + public function get() { + return $this->objectData['calendardata']; + } + + public function put($data) { + + $etag = $this->calendarObject->updateFile($this->objectData['uri'], $data); + $this->objectData['calendardata'] = $data; + $this->objectData['etag'] = $etag; + + return $etag; + } + + /** + * Deletes the calendar object. + */ + public function delete() { + $this->calendarObject->deleteFile($this->objectData['uri']); + } + + /** + * Returns the mime content-type. + * + * @return string + */ + public function getContentType() { + $mime = 'text/calendar; charset=utf-8'; + if (isset($this->objectData['component']) && $this->objectData['component']) { + $mime .= '; component=' . $this->objectData['component']; + } + + return $mime; + } + + /** + * Returns an ETag for this object. + * + * The ETag is an arbitrary string, but MUST be surrounded by double-quotes. + * + * @return string + */ + public function getETag() { + if (isset($this->objectData['etag'])) { + return $this->objectData['etag']; + } else { + return '"' . md5($this->get()) . '"'; + } + } + + /** + * Returns the last modification date as a unix timestamp. + * + * @return int + */ + public function getLastModified() { + return $this->objectData['lastmodified']; + } + + /** + * Returns the size of this object in bytes. + * + * @return int + */ + public function getSize() { + if (array_key_exists('size', $this->objectData)) { + return $this->objectData['size']; + } else { + return strlen($this->get()); + } + } + + /** + * Returns the owner principal. + * + * This must be a url to a principal, or null if there's no owner + * + * @return string|null + */ + public function getOwner() { + return $this->calendarObject->getPrincipalURI(); + } + + public function getGroup() { + return null; + } + + public function getACL() { + return $this->calendarObject->getACL(); + } + + public function setACL(array $acl) { + throw new MethodNotAllowed('Changing ACLs on federated events is not allowed'); + } + + public function getSupportedPrivilegeSet() { + return null; + } + +} diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php index 2ec3e57821201..4f265dde5e981 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php @@ -9,51 +9,131 @@ namespace OCA\DAV\CalDAV\Federation; -use OCA\DAV\CalDAV\SyncService as CalDavSyncService; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Service\ASyncService; +use OCP\AppFramework\Db\TTransactional; +use OCP\AppFramework\Http; use OCP\Federation\ICloudIdManager; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IDBConnection; use Psr\Http\Client\ClientExceptionInterface; use Psr\Log\LoggerInterface; -class FederatedCalendarSyncService { +class FederatedCalendarSyncService extends ASyncService { + use TTransactional; + private const SYNC_TOKEN_PREFIX = 'http://sabre.io/ns/sync/'; public function __construct( + IClientService $clientService, + IConfig $config, private readonly FederatedCalendarMapper $federatedCalendarMapper, private readonly LoggerInterface $logger, - private readonly CalDavSyncService $syncService, + private readonly CalDavBackend $backend, + private readonly IDBConnection $dbConnection, private readonly ICloudIdManager $cloudIdManager, ) { + parent::__construct($clientService, $config); } /** - * @return int Downloaded event count (created or updated). + * Extract and encode credentials from a federated calendar entity. * - * @throws ClientExceptionInterface If syncing the calendar fails. + * @return array{username: string, remoteUrl: string, token: string} */ - public function syncOne(FederatedCalendarEntity $calendar): int { + private function getCredentials(FederatedCalendarEntity $calendar): array { [,, $sharedWith] = explode('/', $calendar->getPrincipaluri()); $calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId(); - $remoteUrl = $calendar->getRemoteUrl(); - $syncToken = $calendar->getSyncTokenForSabre(); // Need to encode the cloud id as it might contain a colon which is not allowed in basic // auth according to RFC 7617 $calDavUser = base64_encode($calDavUser); - $syncResponse = $this->syncService->syncRemoteCalendar( - $remoteUrl, - $calDavUser, - $calendar->getToken(), - $syncToken, - $calendar, - ); + return [ + 'username' => $calDavUser, + 'remoteUrl' => $calendar->getRemoteUrl(), + 'token' => $calendar->getToken(), + ]; + } + + /** + * @return int Downloaded event count (created or updated). + * + * @throws ClientExceptionInterface If syncing the calendar fails. + */ + public function syncOne(FederatedCalendarEntity $calendar): int { + $credentials = $this->getCredentials($calendar); + $syncToken = $calendar->getSyncTokenForSabre(); + + try { + $response = $this->requestSyncReport( + $credentials['remoteUrl'], + $credentials['username'], + $credentials['token'], + $syncToken, + ); + } catch (ClientExceptionInterface $ex) { + if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) { + // Remote server revoked access to the calendar => remove it + $this->federatedCalendarMapper->delete($calendar); + $this->logger->error("Authorization failed, remove federated calendar: {$credentials['remoteUrl']}", [ + 'app' => 'dav', + ]); + throw $ex; + } + $this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]); + throw $ex; + } + + // Process changes from remote + $downloadedEvents = 0; + foreach ($response['response'] as $resource => $status) { + $objectUri = basename($resource); + if (isset($status[200])) { + // Object created or updated + $absoluteUrl = $this->prepareUri($credentials['remoteUrl'], $resource); + $calendarData = $this->download($absoluteUrl, $credentials['username'], $credentials['token']); + $this->atomic(function () use ($calendar, $objectUri, $calendarData): void { + $existingObject = $this->backend->getCalendarObject( + $calendar->getId(), + $objectUri, + CalDavBackend::CALENDAR_TYPE_FEDERATED + ); + if (!$existingObject) { + $this->backend->createCalendarObject( + $calendar->getId(), + $objectUri, + $calendarData, + CalDavBackend::CALENDAR_TYPE_FEDERATED + ); + } else { + $this->backend->updateCalendarObject( + $calendar->getId(), + $objectUri, + $calendarData, + CalDavBackend::CALENDAR_TYPE_FEDERATED + ); + } + }, $this->dbConnection); + $downloadedEvents++; + } else { + // Object deleted + $this->backend->deleteCalendarObject( + $calendar->getId(), + $objectUri, + CalDavBackend::CALENDAR_TYPE_FEDERATED, + true + ); + } + } - $newSyncToken = $syncResponse->getSyncToken(); + $newSyncToken = $response['token']; // Check sync token format and extract the actual sync token integer $matches = []; if (!preg_match('/^http:\/\/sabre\.io\/ns\/sync\/([0-9]+)$/', $newSyncToken, $matches)) { - $this->logger->error("Failed to sync federated calendar at $remoteUrl: New sync token has unexpected format: $newSyncToken", [ + $this->logger->error("Failed to sync federated calendar at {$credentials['remoteUrl']}: New sync token has unexpected format: $newSyncToken", [ 'calendar' => $calendar->toCalendarInfo(), 'newSyncToken' => $newSyncToken, ]); @@ -67,10 +147,58 @@ public function syncOne(FederatedCalendarEntity $calendar): int { $newSyncToken, ); } else { - $this->logger->debug("Sync Token for $remoteUrl unchanged from previous sync"); + $this->logger->debug("Sync Token for {$credentials['remoteUrl']} unchanged from previous sync"); $this->federatedCalendarMapper->updateSyncTime($calendar->getId()); } - return $syncResponse->getDownloadedEvents(); + return $downloadedEvents; + } + + /** + * Create a calendar object on the remote server. + * + * @throws ClientExceptionInterface If the remote request fails. + */ + public function createCalendarObject(FederatedCalendarEntity $calendar, string $name, string $data): string { + $credentials = $this->getCredentials($calendar); + $objectUrl = $this->prepareUri($credentials['remoteUrl'], $name); + + return $this->requestPut( + $objectUrl, + $credentials['username'], + $credentials['token'], + $data, + 'text/calendar; charset=utf-8' + ); + } + + /** + * Update a calendar object on the remote server. + * + * @throws ClientExceptionInterface If the remote request fails. + */ + public function updateCalendarObject(FederatedCalendarEntity $calendar, string $name, string $data): string { + $credentials = $this->getCredentials($calendar); + $objectUrl = $this->prepareUri($credentials['remoteUrl'], $name); + + return $this->requestPut( + $objectUrl, + $credentials['username'], + $credentials['token'], + $data, + 'text/calendar; charset=utf-8' + ); + } + + /** + * Delete a calendar object on the remote server. + * + * @throws ClientExceptionInterface If the remote request fails. + */ + public function deleteCalendarObject(FederatedCalendarEntity $calendar, string $name): void { + $credentials = $this->getCredentials($calendar); + $objectUrl = $this->prepareUri($credentials['remoteUrl'], $name); + + $this->requestDelete($objectUrl, $credentials['username'], $credentials['token']); } } diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index d5ddfa09b248c..f885b9a045f11 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -12,6 +12,7 @@ use OCA\DAV\CalDAV\CalendarHome; use OCA\DAV\CalDAV\CalendarObject; use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Federation\FederatedCalendar; use OCA\DAV\CalDAV\TipBroker; use OCP\IConfig; use Psr\Log\LoggerInterface; @@ -172,8 +173,15 @@ public function calendarObjectChange(RequestInterface $request, ResponseInterfac return; } - /** @var Calendar $calendarNode */ + /** @var Calendar&ICalendar $calendarNode */ $calendarNode = $this->server->tree->getNodeForPath($calendarPath); + + // abort if calendar is federated + if ($calendarNode instanceof FederatedCalendar) { + $this->logger->debug('Not processing scheduling for federated calendar at path: ' . $calendarPath); + return; + } + // extract addresses for owner $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner()); // determine if request is from a sharee diff --git a/apps/dav/lib/Service/ASyncService.php b/apps/dav/lib/Service/ASyncService.php index b9e045cfe86af..97c02be1f67ad 100644 --- a/apps/dav/lib/Service/ASyncService.php +++ b/apps/dav/lib/Service/ASyncService.php @@ -191,4 +191,70 @@ private function isResponseForRequestUri(string $responseUri, string $requestUri rtrim($responseUri, '/'), ); } + + /** + * Push data to the remote server via HTTP PUT. + * Used for creating or updating CalDAV/CardDAV objects. + * + * @param string $url The absolute URL to PUT to + * @param string $username The username for authentication + * @param string $token The authentication token/password + * @param string $data The data to upload + * @param string $contentType The Content-Type header (e.g., 'text/calendar' or 'text/vcard') + * + * @return string The ETag returned by the server + */ + protected function requestPut( + string $url, + string $username, + string $token, + string $data, + string $contentType = 'text/calendar; charset=utf-8', + ): string { + $client = $this->getClient(); + + $options = [ + 'auth' => [$username, $token], + 'body' => $data, + 'headers' => [ + 'Content-Type' => $contentType, + ], + 'verify' => !$this->config->getSystemValue( + 'sharing.federation.allowSelfSignedCertificates', + false, + ), + ]; + + $response = $client->put($url, $options); + + // Extract and return the ETag from the response + $etag = $response->getHeader('ETag'); + return $etag; + } + + /** + * Delete a resource from the remote server via HTTP DELETE. + * Used for deleting CalDAV/CardDAV objects. + * + * @param string $url The absolute URL to DELETE + * @param string $username The username for authentication + * @param string $token The authentication token/password + */ + protected function requestDelete( + string $url, + string $username, + string $token, + ): void { + $client = $this->getClient(); + + $options = [ + 'auth' => [$username, $token], + 'verify' => !$this->config->getSystemValue( + 'sharing.federation.allowSelfSignedCertificates', + false, + ), + ]; + + $client->delete($url, $options); + } } diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php index 64188322f65a1..6e6577b24fa13 100644 --- a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php +++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php @@ -92,11 +92,12 @@ public function testShareReceived(): void { ->willReturn(true); $this->federatedCalendarMapper->expects(self::once()) - ->method('deleteByUri') + ->method('findByUri') ->with( 'principals/users/sharee1', 'ae4b8ab904076fff2b955ea21b1a0d92', - ); + ) + ->willReturn(null); $this->federatedCalendarMapper->expects(self::once()) ->method('insert') @@ -123,6 +124,68 @@ public function testShareReceived(): void { $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); } + public function testShareReceivedWithExistingCalendar(): void { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getShareType') + ->willReturn('user'); + $share->method('getProtocol') + ->willReturn([ + 'version' => 'v1', + 'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + 'displayName' => 'Calendar 1', + 'color' => '#ff0000', + 'access' => 3, + 'components' => 'VEVENT,VTODO', + ]); + $share->method('getShareWith') + ->willReturn('sharee1'); + $share->method('getShareSecret') + ->willReturn('new-token'); + $share->method('getSharedBy') + ->willReturn('user1@nextcloud.remote'); + $share->method('getSharedByDisplayName') + ->willReturn('User 1'); + + $this->calendarFederationConfig->expects(self::once()) + ->method('isFederationEnabled') + ->willReturn(true); + + $existingCalendar = new FederatedCalendarEntity(); + $existingCalendar->setId(10); + $existingCalendar->setPrincipaluri('principals/users/sharee1'); + $existingCalendar->setUri('ae4b8ab904076fff2b955ea21b1a0d92'); + $existingCalendar->setRemoteUrl('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1'); + $existingCalendar->setToken('old-token'); + $existingCalendar->setPermissions(1); + $existingCalendar->setComponents('VEVENT'); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByUri') + ->with( + 'principals/users/sharee1', + 'ae4b8ab904076fff2b955ea21b1a0d92', + ) + ->willReturn($existingCalendar); + + $this->federatedCalendarMapper->expects(self::never()) + ->method('insert'); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('update') + ->willReturnCallback(function (FederatedCalendarEntity $calendar) { + $this->assertEquals('new-token', $calendar->getToken()); + $this->assertEquals(1, $calendar->getPermissions()); + $this->assertEquals('VEVENT,VTODO', $calendar->getComponents()); + return $calendar; + }); + + $this->jobList->expects(self::once()) + ->method('add') + ->with(FederatedCalendarSyncJob::class, ['id' => 10]); + + $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); + } + public function testShareReceivedWithInvalidProtocolVersion(): void { $share = $this->createMock(ICloudFederationShare::class); $share->method('getShareType') @@ -270,7 +333,7 @@ public function testShareReceivedWithIncompleteProtocolData(array $protocol): vo $this->calendarFederationProvider->shareReceived($share); } - public function testShareReceivedWithUnsupportedAccess(): void { + public function testShareReceivedWithReadWriteAccess(): void { $share = $this->createMock(ICloudFederationShare::class); $share->method('getShareType') ->willReturn('user'); @@ -296,6 +359,65 @@ public function testShareReceivedWithUnsupportedAccess(): void { ->method('isFederationEnabled') ->willReturn(true); + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByUri') + ->with( + 'principals/users/sharee1', + 'ae4b8ab904076fff2b955ea21b1a0d92', + ) + ->willReturn(null); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('insert') + ->willReturnCallback(function (FederatedCalendarEntity $calendar) { + $this->assertEquals('principals/users/sharee1', $calendar->getPrincipaluri()); + $this->assertEquals('ae4b8ab904076fff2b955ea21b1a0d92', $calendar->getUri()); + $this->assertEquals('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', $calendar->getRemoteUrl()); + $this->assertEquals('Calendar 1', $calendar->getDisplayName()); + $this->assertEquals('#ff0000', $calendar->getColor()); + $this->assertEquals('token', $calendar->getToken()); + $this->assertEquals('user1@nextcloud.remote', $calendar->getSharedBy()); + $this->assertEquals('User 1', $calendar->getSharedByDisplayName()); + $this->assertEquals(15, $calendar->getPermissions()); // READ | CREATE | UPDATE | DELETE + $this->assertEquals('VEVENT,VTODO', $calendar->getComponents()); + + $calendar->setId(10); + return $calendar; + }); + + $this->jobList->expects(self::once()) + ->method('add') + ->with(FederatedCalendarSyncJob::class, ['id' => 10]); + + $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); + } + + public function testShareReceivedWithUnsupportedAccess(): void { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getShareType') + ->willReturn('user'); + $share->method('getProtocol') + ->willReturn([ + 'version' => 'v1', + 'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + 'displayName' => 'Calendar 1', + 'color' => '#ff0000', + 'access' => 999, // Invalid access value + 'components' => 'VEVENT,VTODO', + ]); + $share->method('getShareWith') + ->willReturn('sharee1'); + $share->method('getShareSecret') + ->willReturn('token'); + $share->method('getSharedBy') + ->willReturn('user1@nextcloud.remote'); + $share->method('getSharedByDisplayName') + ->willReturn('User 1'); + + $this->calendarFederationConfig->expects(self::once()) + ->method('isFederationEnabled') + ->willReturn(true); + $this->federatedCalendarMapper->expects(self::never()) ->method('insert'); $this->jobList->expects(self::never()) diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php index 03a83348d081b..21c410a879e1a 100644 --- a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php @@ -9,13 +9,17 @@ namespace OCA\DAV\Tests\unit\CalDAV\Federation; +use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper; use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService; -use OCA\DAV\CalDAV\SyncService as CalDavSyncService; -use OCA\DAV\CalDAV\SyncServiceResult; use OCP\Federation\ICloudId; use OCP\Federation\ICloudIdManager; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; +use OCP\IConfig; +use OCP\IDBConnection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -26,21 +30,30 @@ class FederatedCalendarSyncServiceTest extends TestCase { private FederatedCalendarMapper&MockObject $federatedCalendarMapper; private LoggerInterface&MockObject $logger; - private CalDavSyncService&MockObject $calDavSyncService; + private CalDavBackend&MockObject $backend; + private IDBConnection&MockObject $dbConnection; private ICloudIdManager&MockObject $cloudIdManager; + private IClientService&MockObject $clientService; + private IConfig&MockObject $config; protected function setUp(): void { parent::setUp(); $this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->calDavSyncService = $this->createMock(CalDavSyncService::class); + $this->backend = $this->createMock(CalDavBackend::class); + $this->dbConnection = $this->createMock(IDBConnection::class); $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->clientService = $this->createMock(IClientService::class); + $this->config = $this->createMock(IConfig::class); $this->federatedCalendarSyncService = new FederatedCalendarSyncService( + $this->clientService, + $this->config, $this->federatedCalendarMapper, $this->logger, - $this->calDavSyncService, + $this->backend, + $this->dbConnection, $this->cloudIdManager, ); } @@ -61,16 +74,24 @@ public function testSyncOne(): void { ->with('user1') ->willReturn($cloudId); - $this->calDavSyncService->expects(self::once()) - ->method('syncRemoteCalendar') - ->with( - 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', - 'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=', - 'token', - 'http://sabre.io/ns/sync/100', - $calendar, - ) - ->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/101', 10)); + // Mock HTTP client for sync report + $client = $this->createMock(IClient::class); + $response = $this->createMock(IResponse::class); + $response->method('getBody') + ->willReturn('http://sabre.io/ns/sync/101'); + + $client->expects(self::once()) + ->method('request') + ->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything()) + ->willReturn($response); + + $this->clientService->method('newClient') + ->willReturn($client); + + $this->config->method('getSystemValueInt') + ->willReturn(30); + $this->config->method('getSystemValue') + ->willReturn(false); $this->federatedCalendarMapper->expects(self::once()) ->method('updateSyncTokenAndTime') @@ -78,7 +99,7 @@ public function testSyncOne(): void { $this->federatedCalendarMapper->expects(self::never()) ->method('updateSyncTime'); - $this->assertEquals(10, $this->federatedCalendarSyncService->syncOne($calendar)); + $this->assertEquals(0, $this->federatedCalendarSyncService->syncOne($calendar)); } public function testSyncOneUnchanged(): void { @@ -97,16 +118,24 @@ public function testSyncOneUnchanged(): void { ->with('user1') ->willReturn($cloudId); - $this->calDavSyncService->expects(self::once()) - ->method('syncRemoteCalendar') - ->with( - 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', - 'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=', - 'token', - 'http://sabre.io/ns/sync/100', - $calendar, - ) - ->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/100', 0)); + // Mock HTTP client for sync report + $client = $this->createMock(IClient::class); + $response = $this->createMock(IResponse::class); + $response->method('getBody') + ->willReturn('http://sabre.io/ns/sync/100'); + + $client->expects(self::once()) + ->method('request') + ->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything()) + ->willReturn($response); + + $this->clientService->method('newClient') + ->willReturn($client); + + $this->config->method('getSystemValueInt') + ->willReturn(30); + $this->config->method('getSystemValue') + ->willReturn(false); $this->federatedCalendarMapper->expects(self::never()) ->method('updateSyncTokenAndTime'); @@ -143,16 +172,24 @@ public function testSyncOneWithUnexpectedSyncTokenFormat(string $syncToken): voi ->with('user1') ->willReturn($cloudId); - $this->calDavSyncService->expects(self::once()) - ->method('syncRemoteCalendar') - ->with( - 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', - 'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=', - 'token', - 'http://sabre.io/ns/sync/100', - $calendar, - ) - ->willReturn(new SyncServiceResult($syncToken, 10)); + // Mock HTTP client for sync report with unexpected token format + $client = $this->createMock(IClient::class); + $response = $this->createMock(IResponse::class); + $response->method('getBody') + ->willReturn('' . $syncToken . ''); + + $client->expects(self::once()) + ->method('request') + ->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything()) + ->willReturn($response); + + $this->clientService->method('newClient') + ->willReturn($client); + + $this->config->method('getSystemValueInt') + ->willReturn(30); + $this->config->method('getSystemValue') + ->willReturn(false); $this->federatedCalendarMapper->expects(self::never()) ->method('updateSyncTokenAndTime'); diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarTest.php new file mode 100644 index 0000000000000..431c520b5e7b1 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarTest.php @@ -0,0 +1,404 @@ +federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class); + $this->federatedCalendarService = $this->createMock(FederatedCalendarSyncService::class); + $this->caldavBackend = $this->createMock(CalDavBackend::class); + + $this->federationInfo = new FederatedCalendarEntity(); + $this->federationInfo->setId(10); + $this->federationInfo->setPrincipaluri('principals/users/user1'); + $this->federationInfo->setUri('calendar-uri'); + $this->federationInfo->setDisplayName('Federated Calendar'); + $this->federationInfo->setColor('#ff0000'); + $this->federationInfo->setSharedBy('user2@nextcloud.remote'); + $this->federationInfo->setSharedByDisplayName('User 2'); + $this->federationInfo->setPermissions(Constants::PERMISSION_READ); + $this->federationInfo->setLastSync(1234567890); + + $this->federatedCalendarMapper->method('findByUri') + ->with('principals/users/user1', 'calendar-uri') + ->willReturn($this->federationInfo); + + $calendarInfo = [ + 'principaluri' => 'principals/users/user1', + 'id' => 10, + 'uri' => 'calendar-uri', + ]; + + $this->federatedCalendar = new FederatedCalendar( + $this->federatedCalendarMapper, + $this->federatedCalendarService, + $this->caldavBackend, + $calendarInfo, + ); + } + + public function testGetResourceId(): void { + $this->assertEquals(10, $this->federatedCalendar->getResourceId()); + } + + public function testGetName(): void { + $this->assertEquals('calendar-uri', $this->federatedCalendar->getName()); + } + + public function testSetName(): void { + $this->expectException(MethodNotAllowed::class); + $this->expectExceptionMessage('Renaming federated calendars is not allowed'); + $this->federatedCalendar->setName('new-name'); + } + + public function testGetPrincipalURI(): void { + $this->assertEquals('principals/users/user1', $this->federatedCalendar->getPrincipalURI()); + } + + public function testGetOwner(): void { + $expected = 'principals/remote-users/' . base64_encode('user2@nextcloud.remote'); + $this->assertEquals($expected, $this->federatedCalendar->getOwner()); + } + + public function testGetGroup(): void { + $this->assertNull($this->federatedCalendar->getGroup()); + } + + public function testGetACLWithReadOnlyPermissions(): void { + $this->federationInfo->setPermissions(Constants::PERMISSION_READ); + + $acl = $this->federatedCalendar->getACL(); + + $this->assertCount(3, $acl); + // Check basic read permissions + $this->assertEquals('{DAV:}read', $acl[0]['privilege']); + $this->assertTrue($acl[0]['protected']); + $this->assertEquals('{DAV:}read-acl', $acl[1]['privilege']); + $this->assertTrue($acl[1]['protected']); + $this->assertEquals('{DAV:}write-properties', $acl[2]['privilege']); + $this->assertTrue($acl[2]['protected']); + } + + public function testGetACLWithCreatePermission(): void { + $this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_CREATE); + + $acl = $this->federatedCalendar->getACL(); + + $this->assertCount(4, $acl); + // Check that create permission is added + $privileges = array_column($acl, 'privilege'); + $this->assertContains('{DAV:}bind', $privileges); + } + + public function testGetACLWithUpdatePermission(): void { + $this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE); + + $acl = $this->federatedCalendar->getACL(); + + $this->assertCount(4, $acl); + // Check that update permission is added (write-content, not write-properties which is already in base ACL) + $privileges = array_column($acl, 'privilege'); + $this->assertContains('{DAV:}write-content', $privileges); + } + + public function testGetACLWithDeletePermission(): void { + $this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_DELETE); + + $acl = $this->federatedCalendar->getACL(); + + $this->assertCount(4, $acl); + // Check that delete permission is added + $privileges = array_column($acl, 'privilege'); + $this->assertContains('{DAV:}unbind', $privileges); + } + + public function testGetACLWithAllPermissions(): void { + $this->federationInfo->setPermissions( + Constants::PERMISSION_READ + | Constants::PERMISSION_CREATE + | Constants::PERMISSION_UPDATE + | Constants::PERMISSION_DELETE + ); + + $acl = $this->federatedCalendar->getACL(); + + $this->assertCount(6, $acl); + $privileges = array_column($acl, 'privilege'); + $this->assertContains('{DAV:}read', $privileges); + $this->assertContains('{DAV:}bind', $privileges); + $this->assertContains('{DAV:}write-content', $privileges); + $this->assertContains('{DAV:}write-properties', $privileges); + $this->assertContains('{DAV:}unbind', $privileges); + } + + public function testSetACL(): void { + $this->expectException(MethodNotAllowed::class); + $this->expectExceptionMessage('Changing ACLs on federated calendars is not allowed'); + $this->federatedCalendar->setACL([]); + } + + public function testGetSupportedPrivilegeSet(): void { + $this->assertNull($this->federatedCalendar->getSupportedPrivilegeSet()); + } + + public function testGetProperties(): void { + $properties = $this->federatedCalendar->getProperties([ + '{DAV:}displayname', + '{http://apple.com/ns/ical/}calendar-color', + ]); + + $this->assertEquals('Federated Calendar', $properties['{DAV:}displayname']); + $this->assertEquals('#ff0000', $properties['{http://apple.com/ns/ical/}calendar-color']); + } + + public function testPropPatchWithDisplayName(): void { + $propPatch = $this->createMock(PropPatch::class); + $propPatch->method('getMutations') + ->willReturn([ + '{DAV:}displayname' => 'New Calendar Name', + ]); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('update') + ->willReturnCallback(function (FederatedCalendarEntity $entity) { + $this->assertEquals('New Calendar Name', $entity->getDisplayName()); + return $entity; + }); + + $propPatch->expects(self::once()) + ->method('setResultCode') + ->with('{DAV:}displayname', 200); + + $this->federatedCalendar->propPatch($propPatch); + } + + public function testPropPatchWithColor(): void { + $propPatch = $this->createMock(PropPatch::class); + $propPatch->method('getMutations') + ->willReturn([ + '{http://apple.com/ns/ical/}calendar-color' => '#00ff00', + ]); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('update') + ->willReturnCallback(function (FederatedCalendarEntity $entity) { + $this->assertEquals('#00ff00', $entity->getColor()); + return $entity; + }); + + $propPatch->expects(self::once()) + ->method('setResultCode') + ->with('{http://apple.com/ns/ical/}calendar-color', 200); + + $this->federatedCalendar->propPatch($propPatch); + } + + public function testPropPatchWithNoMutations(): void { + $propPatch = $this->createMock(PropPatch::class); + $propPatch->method('getMutations') + ->willReturn([]); + + $this->federatedCalendarMapper->expects(self::never()) + ->method('update'); + + $propPatch->expects(self::never()) + ->method('handle'); + + $this->federatedCalendar->propPatch($propPatch); + } + + public function testGetChildACL(): void { + $this->assertEquals($this->federatedCalendar->getACL(), $this->federatedCalendar->getChildACL()); + } + + public function testGetLastModified(): void { + $this->assertEquals(1234567890, $this->federatedCalendar->getLastModified()); + } + + public function testDelete(): void { + $this->federatedCalendarMapper->expects(self::once()) + ->method('deleteById') + ->with(10); + + $this->federatedCalendar->delete(); + } + + public function testCreateDirectory(): void { + $this->expectException(MethodNotAllowed::class); + $this->expectExceptionMessage('Creating nested collection is not allowed'); + $this->federatedCalendar->createDirectory('test'); + } + + public function testCalendarQuery(): void { + $filters = ['comp-filter' => ['name' => 'VEVENT']]; + $expectedUris = ['event1.ics', 'event2.ics']; + + $this->caldavBackend->expects(self::once()) + ->method('calendarQuery') + ->with(10, $filters, 2) // 2 is CALENDAR_TYPE_FEDERATED + ->willReturn($expectedUris); + + $result = $this->federatedCalendar->calendarQuery($filters); + $this->assertEquals($expectedUris, $result); + } + + public function testGetChild(): void { + $objectData = [ + 'id' => 1, + 'uri' => 'event1.ics', + 'calendardata' => 'BEGIN:VCALENDAR...', + ]; + + $this->caldavBackend->expects(self::once()) + ->method('getCalendarObject') + ->with(10, 'event1.ics', 2) // 2 is CALENDAR_TYPE_FEDERATED + ->willReturn($objectData); + + $child = $this->federatedCalendar->getChild('event1.ics'); + $this->assertInstanceOf(FederatedCalendarObject::class, $child); + } + + public function testGetChildNotFound(): void { + $this->caldavBackend->expects(self::once()) + ->method('getCalendarObject') + ->with(10, 'nonexistent.ics', 2) + ->willReturn(null); + + $this->expectException(NotFound::class); + $this->federatedCalendar->getChild('nonexistent.ics'); + } + + public function testGetChildren(): void { + $objects = [ + ['id' => 1, 'uri' => 'event1.ics', 'calendardata' => 'BEGIN:VCALENDAR...'], + ['id' => 2, 'uri' => 'event2.ics', 'calendardata' => 'BEGIN:VCALENDAR...'], + ]; + + $this->caldavBackend->expects(self::once()) + ->method('getCalendarObjects') + ->with(10, 2) // 2 is CALENDAR_TYPE_FEDERATED + ->willReturn($objects); + + $children = $this->federatedCalendar->getChildren(); + $this->assertCount(2, $children); + $this->assertInstanceOf(FederatedCalendarObject::class, $children[0]); + $this->assertInstanceOf(FederatedCalendarObject::class, $children[1]); + } + + public function testGetMultipleChildren(): void { + $paths = ['event1.ics', 'event2.ics']; + $objects = [ + ['id' => 1, 'uri' => 'event1.ics', 'calendardata' => 'BEGIN:VCALENDAR...'], + ['id' => 2, 'uri' => 'event2.ics', 'calendardata' => 'BEGIN:VCALENDAR...'], + ]; + + $this->caldavBackend->expects(self::once()) + ->method('getMultipleCalendarObjects') + ->with(10, $paths, 2) // 2 is CALENDAR_TYPE_FEDERATED + ->willReturn($objects); + + $children = $this->federatedCalendar->getMultipleChildren($paths); + $this->assertCount(2, $children); + $this->assertInstanceOf(FederatedCalendarObject::class, $children[0]); + $this->assertInstanceOf(FederatedCalendarObject::class, $children[1]); + } + + public function testChildExists(): void { + $this->caldavBackend->expects(self::once()) + ->method('getCalendarObject') + ->with(10, 'event1.ics', 2) + ->willReturn(['id' => 1, 'uri' => 'event1.ics']); + + $result = $this->federatedCalendar->childExists('event1.ics'); + $this->assertTrue($result); + } + + public function testChildNotExists(): void { + $this->caldavBackend->expects(self::once()) + ->method('getCalendarObject') + ->with(10, 'nonexistent.ics', 2) + ->willReturn(null); + + $result = $this->federatedCalendar->childExists('nonexistent.ics'); + $this->assertFalse($result); + } + + public function testCreateFile(): void { + $calendarData = 'BEGIN:VCALENDAR...END:VCALENDAR'; + $remoteEtag = '"remote-etag-123"'; + $localEtag = '"local-etag-456"'; + + $this->federatedCalendarService->expects(self::once()) + ->method('createCalendarObject') + ->with($this->federationInfo, 'event1.ics', $calendarData) + ->willReturn($remoteEtag); + + $this->caldavBackend->expects(self::once()) + ->method('createCalendarObject') + ->with(10, 'event1.ics', $calendarData, 2) + ->willReturn($localEtag); + + $result = $this->federatedCalendar->createFile('event1.ics', $calendarData); + $this->assertEquals($localEtag, $result); + } + + public function testUpdateFile(): void { + $calendarData = 'BEGIN:VCALENDAR...UPDATED...END:VCALENDAR'; + $remoteEtag = '"remote-etag-updated"'; + $localEtag = '"local-etag-updated"'; + + $this->federatedCalendarService->expects(self::once()) + ->method('updateCalendarObject') + ->with($this->federationInfo, 'event1.ics', $calendarData) + ->willReturn($remoteEtag); + + $this->caldavBackend->expects(self::once()) + ->method('updateCalendarObject') + ->with(10, 'event1.ics', $calendarData, 2) + ->willReturn($localEtag); + + $result = $this->federatedCalendar->updateFile('event1.ics', $calendarData); + $this->assertEquals($localEtag, $result); + } + + public function testDeleteFile(): void { + $this->federatedCalendarService->expects(self::once()) + ->method('deleteCalendarObject') + ->with($this->federationInfo, 'event1.ics'); + + $this->caldavBackend->expects(self::once()) + ->method('deleteCalendarObject') + ->with(10, 'event1.ics', 2); + + $this->federatedCalendar->deleteFile('event1.ics'); + } +}