feat: calendar federation write

Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
This commit is contained in:
SebastianKrupinski 2026-02-02 21:39:39 -05:00
parent 128d708ac3
commit fb1b0a224a
15 changed files with 1230 additions and 109 deletions

View file

@ -77,6 +77,7 @@ return array(
'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',

View file

@ -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',

View file

@ -43,9 +43,9 @@ class CalendarProvider implements ICalendarProvider {
});
}
$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 @@ class CalendarProvider implements ICalendarProvider {
);
}
$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);

View file

@ -104,9 +104,10 @@ class CalendarFederationProvider implements ICloudFederationProvider {
);
}
// 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 @@ class CalendarFederationProvider implements ICloudFederationProvider {
$sharedWithPrincipal = 'principals/users/' . $share->getShareWith();
// Delete existing incoming federated share first
$this->federatedCalendarMapper->deleteByUri($sharedWithPrincipal, $calendarUri);
$calendar = $this->federatedCalendarMapper->findByUri($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);
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(),

View file

@ -10,29 +10,251 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
use OCP\IConfig;
use OCP\IL10N;
use OCP\Constants;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend;
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 int $calendarId;
private string $calendarUri;
private FederatedCalendarEntity $federationInfo;
class FederatedCalendar extends Calendar {
public function __construct(
Backend\BackendInterface $caldavBackend,
$calendarInfo,
IL10N $l10n,
IConfig $config,
LoggerInterface $logger,
private readonly LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly FederatedCalendarSyncService $federatedCalendarService,
private readonly Backend\BackendInterface $caldavBackend,
$calendarInfo,
) {
parent::__construct($caldavBackend, $calendarInfo, $l10n, $config, $logger);
$this->principalUri = $calendarInfo['principaluri'];
$this->calendarId = $calendarInfo['id'];
$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() {
$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,
];
}
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) {
throw new NotFound('Calendar object not found');
}
$obj['acl'] = $this->getChildACL();
return new FederatedCalendarObject($this, $obj);
}
public function getChildren() {
$objs = $this->caldavBackend->getCalendarObjects($this->federationInfo->getId(), $this->getCalendarType());
$children = [];
foreach ($objs as $obj) {
$obj['acl'] = $this->getChildACL();
$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) {
$obj['acl'] = $this->getChildACL();
$children[] = new FederatedCalendarObject($this, $obj);
}
return $children;
}
public function childExists($name) {
$obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
if (!$obj) {
return false;
} else {
return true;
}
}
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
$localEtag = $this->caldavBackend->createCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType());
return $localEtag;
}
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());
}
}

View file

@ -94,8 +94,8 @@ class FederatedCalendarEntity extends Entity {
'{' . \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(),
];
}
}

View file

@ -9,34 +9,26 @@ declare(strict_types=1);
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->caldavBackend,
$calendarInfo,
$this->l10n,
$this->config,
$this->logger,
$this->federatedCalendarMapper,
$this->federatedCalendarService,
$this->caldavBackend,
$calendarInfo,
);
}
}

View file

@ -51,8 +51,7 @@ class FederatedCalendarImpl implements ICalendar, ICalendarIsShared, ICalendarIs
}
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 @@ class FederatedCalendarImpl implements ICalendar, ICalendarIsShared, ICalendarIs
}
public function isWritable(): bool {
return false;
$permissions = $this->getPermissions();
return ($permissions & Constants::PERMISSION_UPDATE) !== 0;
}
public function isEnabled(): bool {

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use Sabre\CalDAV\ICalendarObject;
use Sabre\DAV\Exception\MethodNotAllowed;
use Sabre\DAVACL\IACL;
class FederatedCalendarObject implements ICalendarObject, IACL {
public function __construct(
protected FederatedCalendar $calendarObject,
protected $objectData,
) {
}
public function getName() {
return $this->objectData['uri'];
}
public function setName($name) {
throw new \Exception('Not implemented');
}
public function get() {
return $this->objectData['calendardata'];
}
public function put($calendarData) {
$etag = $this->calendarObject->updateFile($this->objectData['uri'], $calendarData);
$this->objectData['calendardata'] = $calendarData;
$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;
}
}

View file

@ -9,20 +9,52 @@ declare(strict_types=1);
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);
}
/**
* Extract and encode credentials from a federated calendar entity.
*
* @return array{username: string, remoteUrl: string, token: string}
*/
private function getCredentials(FederatedCalendarEntity $calendar): array {
[,, $sharedWith] = explode('/', $calendar->getPrincipaluri());
$calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId();
// 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);
return [
'username' => $calDavUser,
'remoteUrl' => $calendar->getRemoteUrl(),
'token' => $calendar->getToken(),
];
}
/**
@ -31,29 +63,77 @@ class FederatedCalendarSyncService {
* @throws ClientExceptionInterface If syncing the calendar fails.
*/
public function syncOne(FederatedCalendarEntity $calendar): int {
[,, $sharedWith] = explode('/', $calendar->getPrincipaluri());
$calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId();
$remoteUrl = $calendar->getRemoteUrl();
$credentials = $this->getCredentials($calendar);
$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);
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;
}
$syncResponse = $this->syncService->syncRemoteCalendar(
$remoteUrl,
$calDavUser,
$calendar->getToken(),
$syncToken,
$calendar,
);
// 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 @@ class FederatedCalendarSyncService {
$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']);
}
}

View file

@ -12,6 +12,7 @@ use OCA\DAV\CalDAV\Calendar;
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 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
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

View file

@ -191,4 +191,70 @@ abstract class ASyncService {
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 is_array($etag) ? $etag[0] : (string)$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);
}
}

View file

@ -92,11 +92,12 @@ class CalendarFederationProviderTest extends TestCase {
->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 @@ class CalendarFederationProviderTest extends TestCase {
$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 @@ class CalendarFederationProviderTest extends TestCase {
$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 @@ class CalendarFederationProviderTest extends TestCase {
->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())

View file

@ -9,13 +9,17 @@ declare(strict_types=1);
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 @@ class FederatedCalendarSyncServiceTest extends TestCase {
->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('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:sync-token>http://sabre.io/ns/sync/101</d:sync-token></d:multistatus>');
$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 @@ class FederatedCalendarSyncServiceTest extends TestCase {
$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 @@ class FederatedCalendarSyncServiceTest extends TestCase {
->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('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:sync-token>http://sabre.io/ns/sync/100</d:sync-token></d:multistatus>');
$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 @@ class FederatedCalendarSyncServiceTest extends TestCase {
->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('<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:sync-token>' . $syncToken . '</d:sync-token></d:multistatus>');
$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');

View file

@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Federation\FederatedCalendar;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarObject;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCP\Constants;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend\BackendInterface;
use Sabre\DAV\Exception\MethodNotAllowed;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\PropPatch;
use Test\TestCase;
class FederatedCalendarTest extends TestCase {
private FederatedCalendar $federatedCalendar;
private LoggerInterface&MockObject $logger;
private FederatedCalendarMapper&MockObject $federatedCalendarMapper;
private FederatedCalendarSyncService&MockObject $federatedCalendarService;
private BackendInterface&MockObject $caldavBackend;
private FederatedCalendarEntity $federationInfo;
protected function setUp(): void {
parent::setUp();
$this->logger = $this->createMock(LoggerInterface::class);
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->federatedCalendarService = $this->createMock(FederatedCalendarSyncService::class);
$this->caldavBackend = $this->createMock(BackendInterface::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->logger,
$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');
}
}