fix: calendar subscription memory exhaustion

Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
pull/56784/head
SebastianKrupinski 2025-11-27 09:56:01 +07:00
parent 4185dfb599
commit 1a0535aa75
7 changed files with 476 additions and 329 deletions

@ -1066,9 +1066,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param int $calendarType * @param int $calendarType
* @return array * @return array
*/ */
public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array { public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, array $fields = []):array {
$query = $this->db->getQueryBuilder(); $query = $this->db->getQueryBuilder();
$query->select(['id','uid', 'etag', 'uri', 'calendardata']) $query->select($fields ?: ['id', 'uid', 'etag', 'uri', 'calendardata'])
->from('calendarobjects') ->from('calendarobjects')
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
@ -1077,12 +1077,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$result = []; $result = [];
while (($row = $stmt->fetchAssociative()) !== false) { while (($row = $stmt->fetchAssociative()) !== false) {
$result[$row['uid']] = [ $result[$row['uid']] = $row;
'id' => $row['id'],
'etag' => $row['etag'],
'uri' => $row['uri'],
'calendardata' => $row['calendardata'],
];
} }
$stmt->closeCursor(); $stmt->closeCursor();

@ -23,9 +23,6 @@ use Sabre\VObject\UUIDUtil;
*/ */
class ImportService { class ImportService {
/** @var resource */
private $source;
public function __construct( public function __construct(
private CalDavBackend $backend, private CalDavBackend $backend,
) { ) {
@ -44,18 +41,15 @@ class ImportService {
if (!is_resource($source)) { if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource'); throw new InvalidArgumentException('Invalid import source must be a file resource');
} }
$this->source = $source;
switch ($options->getFormat()) { switch ($options->getFormat()) {
case 'ical': case 'ical':
return $this->importProcess($calendar, $options, $this->importText(...)); return $this->importProcess($source, $calendar, $options, $this->importText(...));
break; break;
case 'jcal': case 'jcal':
return $this->importProcess($calendar, $options, $this->importJson(...)); return $this->importProcess($source, $calendar, $options, $this->importJson(...));
break; break;
case 'xcal': case 'xcal':
return $this->importProcess($calendar, $options, $this->importXml(...)); return $this->importProcess($source, $calendar, $options, $this->importXml(...));
break; break;
default: default:
throw new InvalidArgumentException('Invalid import format'); throw new InvalidArgumentException('Invalid import format');
@ -65,10 +59,15 @@ class ImportService {
/** /**
* Generates object stream from a text formatted source (ical) * Generates object stream from a text formatted source (ical)
* *
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar> * @return Generator<\Sabre\VObject\Component\VCalendar>
*/ */
private function importText(): Generator { public function importText($source): Generator {
$importer = new TextImporter($this->source); if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$importer = new TextImporter($source);
$structure = $importer->structure(); $structure = $importer->structure();
$sObjectPrefix = $importer::OBJECT_PREFIX; $sObjectPrefix = $importer::OBJECT_PREFIX;
$sObjectSuffix = $importer::OBJECT_SUFFIX; $sObjectSuffix = $importer::OBJECT_SUFFIX;
@ -113,10 +112,15 @@ class ImportService {
/** /**
* Generates object stream from a xml formatted source (xcal) * Generates object stream from a xml formatted source (xcal)
* *
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar> * @return Generator<\Sabre\VObject\Component\VCalendar>
*/ */
private function importXml(): Generator { public function importXml($source): Generator {
$importer = new XmlImporter($this->source); if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
$importer = new XmlImporter($source);
$structure = $importer->structure(); $structure = $importer->structure();
$sObjectPrefix = $importer::OBJECT_PREFIX; $sObjectPrefix = $importer::OBJECT_PREFIX;
$sObjectSuffix = $importer::OBJECT_SUFFIX; $sObjectSuffix = $importer::OBJECT_SUFFIX;
@ -155,11 +159,16 @@ class ImportService {
/** /**
* Generates object stream from a json formatted source (jcal) * Generates object stream from a json formatted source (jcal)
* *
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar> * @return Generator<\Sabre\VObject\Component\VCalendar>
*/ */
private function importJson(): Generator { public function importJson($source): Generator {
if (!is_resource($source)) {
throw new InvalidArgumentException('Invalid import source must be a file resource');
}
/** @var VCALENDAR $importer */ /** @var VCALENDAR $importer */
$importer = Reader::readJson($this->source); $importer = Reader::readJson($source);
// calendar time zones // calendar time zones
$timezones = []; $timezones = [];
foreach ($importer->VTIMEZONE as $timezone) { foreach ($importer->VTIMEZONE as $timezone) {
@ -212,17 +221,18 @@ class ImportService {
* *
* @since 32.0.0 * @since 32.0.0
* *
* @param resource $source
* @param CalendarImportOptions $options * @param CalendarImportOptions $options
* @param callable $generator<CalendarImportOptions>: Generator<\Sabre\VObject\Component\VCalendar> * @param callable $generator<CalendarImportOptions>: Generator<\Sabre\VObject\Component\VCalendar>
* *
* @return array<string,array<string,string|array<string>>> * @return array<string,array<string,string|array<string>>>
*/ */
public function importProcess(CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array {
$calendarId = $calendar->getKey(); $calendarId = $calendar->getKey();
$calendarUri = $calendar->getUri(); $calendarUri = $calendar->getUri();
$principalUri = $calendar->getPrincipalUri(); $principalUri = $calendar->getPrincipalUri();
$outcome = []; $outcome = [];
foreach ($generator() as $vObject) { foreach ($generator($source) as $vObject) {
$components = $vObject->getBaseComponents(); $components = $vObject->getBaseComponents();
// determine if the object has no base component types // determine if the object has no base component types
if (count($components) === 0) { if (count($components) === 0) {

@ -14,7 +14,6 @@ use OCP\Http\Client\IClientService;
use OCP\Http\Client\LocalServerException; use OCP\Http\Client\LocalServerException;
use OCP\IAppConfig; use OCP\IAppConfig;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Sabre\VObject\Reader;
class Connection { class Connection {
public function __construct( public function __construct(
@ -26,8 +25,10 @@ class Connection {
/** /**
* gets webcal feed from remote server * gets webcal feed from remote server
*
* @return array{data: resource, format: string}|null
*/ */
public function queryWebcalFeed(array $subscription): ?string { public function queryWebcalFeed(array $subscription): ?array {
$subscriptionId = $subscription['id']; $subscriptionId = $subscription['id'];
$url = $this->cleanURL($subscription['source']); $url = $this->cleanURL($subscription['source']);
if ($url === null) { if ($url === null) {
@ -54,6 +55,7 @@ class Connection {
'User-Agent' => $uaString, 'User-Agent' => $uaString,
'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml', 'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml',
], ],
'stream' => true,
]; ];
$user = parse_url($subscription['source'], PHP_URL_USER); $user = parse_url($subscription['source'], PHP_URL_USER);
@ -77,42 +79,22 @@ class Connection {
return null; return null;
} }
$body = $response->getBody();
$contentType = $response->getHeader('Content-Type'); $contentType = $response->getHeader('Content-Type');
$contentType = explode(';', $contentType, 2)[0]; $contentType = explode(';', $contentType, 2)[0];
switch ($contentType) {
case 'application/calendar+json': $format = match ($contentType) {
try { 'application/calendar+json' => 'jcal',
$jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); 'application/calendar+xml' => 'xcal',
} catch (Exception $ex) { default => 'ical',
// In case of a parsing error return null };
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
return null; // With 'stream' => true, getBody() returns the underlying stream resource
} $stream = $response->getBody();
return $jCalendar->serialize(); if (!is_resource($stream)) {
return null;
case 'application/calendar+xml':
try {
$xCalendar = Reader::readXML($body);
} catch (Exception $ex) {
// In case of a parsing error return null
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
return null;
}
return $xCalendar->serialize();
case 'text/calendar':
default:
try {
$vCalendar = Reader::read($body);
} catch (Exception $ex) {
// In case of a parsing error return null
$this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
return null;
}
return $vCalendar->serialize();
} }
return ['data' => $stream, 'format' => $format];
} }
/** /**

@ -9,18 +9,14 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\WebcalCaching; namespace OCA\DAV\CalDAV\WebcalCaching;
use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Import\ImportService;
use OCP\AppFramework\Utility\ITimeFactory; use OCP\AppFramework\Utility\ITimeFactory;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\PropPatch; use Sabre\DAV\PropPatch;
use Sabre\VObject\Component; use Sabre\VObject\Component;
use Sabre\VObject\DateTimeParser; use Sabre\VObject\DateTimeParser;
use Sabre\VObject\InvalidDataException; use Sabre\VObject\InvalidDataException;
use Sabre\VObject\ParseException; use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\NoInstancesException;
use Sabre\VObject\Splitter\ICalendar;
use Sabre\VObject\UUIDUtil; use Sabre\VObject\UUIDUtil;
use function count; use function count;
@ -36,20 +32,20 @@ class RefreshWebcalService {
private LoggerInterface $logger, private LoggerInterface $logger,
private Connection $connection, private Connection $connection,
private ITimeFactory $time, private ITimeFactory $time,
private ImportService $importService,
) { ) {
} }
public function refreshSubscription(string $principalUri, string $uri) { public function refreshSubscription(string $principalUri, string $uri) {
$subscription = $this->getSubscription($principalUri, $uri); $subscription = $this->getSubscription($principalUri, $uri);
$mutations = [];
if (!$subscription) { if (!$subscription) {
return; return;
} }
// Check the refresh rate if there is any // Check the refresh rate if there is any
if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) { if (!empty($subscription[self::REFRESH_RATE])) {
// add the refresh interval to the lastmodified timestamp // add the refresh interval to the last modified timestamp
$refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']); $refreshInterval = new \DateInterval($subscription[self::REFRESH_RATE]);
$updateTime = $this->time->getDateTime(); $updateTime = $this->time->getDateTime();
$updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval); $updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval);
if ($updateTime->getTimestamp() > $this->time->getTime()) { if ($updateTime->getTimestamp() > $this->time->getTime()) {
@ -57,109 +53,116 @@ class RefreshWebcalService {
} }
} }
$result = $this->connection->queryWebcalFeed($subscription);
$webcalData = $this->connection->queryWebcalFeed($subscription); if (!$result) {
if (!$webcalData) {
return; return;
} }
$localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); $data = $result['data'];
$format = $result['format'];
$stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1; $stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
$stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1; $stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
$stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1; $stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
try { try {
$splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING); $existingObjects = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, ['id', 'uid', 'etag', 'uri']);
while ($vObject = $splitter->getNext()) {
/** @var Component $vObject */
$compName = null;
$uid = null;
foreach ($vObject->getComponents() as $component) {
if ($component->name === 'VTIMEZONE') {
continue;
}
$compName = $component->name;
if ($stripAlarms) { $generator = match ($format) {
unset($component->{'VALARM'}); 'xcal' => $this->importService->importXml(...),
} 'jcal' => $this->importService->importJson(...),
if ($stripAttachments) { default => $this->importService->importText(...)
unset($component->{'ATTACH'}); };
}
$uid = $component->{ 'UID' }->getValue();
}
if ($stripTodos && $compName === 'VTODO') {
continue;
}
if (!isset($uid)) { foreach ($generator($data) as $vObject) {
continue; /** @var Component\VCalendar $vObject */
} $vBase = $vObject->getBaseComponent();
try { if (!$vBase->UID) {
$denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize());
} catch (InvalidDataException|Forbidden $ex) {
$this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]);
continue; continue;
} }
// Find all identical sets and remove them from the update // Some calendar providers (e.g. Google, MS) use very long UIDs
if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) { if (strlen($vBase->UID->getValue()) > 512) {
unset($localData[$uid]); $this->logger->warning('Skipping calendar object with overly long UID from subscription "{subscriptionId}"', [
'subscriptionId' => $subscription['id'],
'uid' => $vBase->UID->getValue(),
]);
continue; continue;
} }
$vObjectCopy = clone $vObject; if ($stripTodos && $vBase->name === 'VTODO') {
$identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]);
if ($identical) {
unset($localData[$uid]);
continue; continue;
} }
// Find all modified sets and update them if ($stripAlarms || $stripAttachments) {
if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) { foreach ($vObject->getComponents() as $component) {
$this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); if ($component->name === 'VTIMEZONE') {
unset($localData[$uid]); continue;
continue; }
if ($stripAlarms) {
$component->remove('VALARM');
}
if ($stripAttachments) {
$component->remove('ATTACH');
}
}
} }
// Only entirely new events get created here $sObject = $vObject->serialize();
try { $uid = $vBase->UID->getValue();
$objectUri = $this->getRandomCalendarObjectUri(); $etag = md5($sObject);
$this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
} catch (NoInstancesException|BadRequest $ex) { // No existing object with this UID, create it
$this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); if (!isset($existingObjects[$uid])) {
try {
$this->calDavBackend->createCalendarObject(
$subscription['id'],
UUIDUtil::getUUID() . '.ics',
$sObject,
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
);
} catch (\Exception $ex) {
$this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', [
'exception' => $ex,
'subscriptionId' => $subscription['id'],
'source' => $subscription['source'],
]);
}
} elseif ($existingObjects[$uid]['etag'] !== $etag) {
// Existing object with this UID but different etag, update it
$this->calDavBackend->updateCalendarObject(
$subscription['id'],
$existingObjects[$uid]['uri'],
$sObject,
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
);
unset($existingObjects[$uid]);
} else {
// Existing object with same etag, just remove from tracking
unset($existingObjects[$uid]);
} }
} }
$ids = array_map(static function ($dataSet): int { // Clean up objects that no longer exist in the remote feed
return (int)$dataSet['id']; // The only events left over should be those not found upstream
}, $localData); if (!empty($existingObjects)) {
$uris = array_map(static function ($dataSet): string { $ids = array_map('intval', array_column($existingObjects, 'id'));
return $dataSet['uri']; $uris = array_column($existingObjects, 'uri');
}, $localData); $this->calDavBackend->purgeCachedEventsForSubscription((int)$subscription['id'], $ids, $uris);
if (!empty($ids) && !empty($uris)) {
// Clean up on aisle 5
// The only events left over in the $localData array should be those that don't exist upstream
// All deleted VObjects from upstream are removed
$this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris);
} }
$newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData); // Update refresh rate from the last processed object
if ($newRefreshRate) { if (isset($vObject)) {
$mutations[self::REFRESH_RATE] = $newRefreshRate; $this->updateRefreshRate($subscription, $vObject);
} }
$this->updateSubscription($subscription, $mutations);
} catch (ParseException $ex) { } catch (ParseException $ex) {
$this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]); $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]);
} finally {
// Close the data stream to free resources
if (is_resource($data)) {
fclose($data);
}
} }
} }
@ -181,84 +184,34 @@ class RefreshWebcalService {
return $subscriptions[0]; return $subscriptions[0];
} }
/** /**
* check if: * Update refresh rate from calendar object if:
* - current subscription stores a refreshrate * - current subscription does not store a refreshrate
* - the webcal feed suggests a refreshrate * - the webcal feed suggests a valid refreshrate
* - return suggested refreshrate if user didn't set a custom one
*
*/ */
private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string { private function updateRefreshRate(array $subscription, Component\VCalendar $vCalendar): void {
// if there is no refreshrate stored in the database, check the webcal feed // if there is already a refreshrate stored in the database, don't override it
// whether it suggests any refresh rate and store that in the database if (!empty($subscription[self::REFRESH_RATE])) {
if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) { return;
return null;
} }
/** @var Component\VCalendar $vCalendar */ $refreshRate = $vCalendar->{'REFRESH-INTERVAL'}?->getValue()
$vCalendar = Reader::read($webcalData); ?? $vCalendar->{'X-PUBLISHED-TTL'}?->getValue();
$newRefreshRate = null;
if (isset($vCalendar->{'X-PUBLISHED-TTL'})) {
$newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue();
}
if (isset($vCalendar->{'REFRESH-INTERVAL'})) {
$newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue();
}
if (!$newRefreshRate) { if ($refreshRate === null) {
return null; return;
} }
// check if new refresh rate is even valid // check if refresh rate is valid
try { try {
DateTimeParser::parseDuration($newRefreshRate); DateTimeParser::parseDuration($refreshRate);
} catch (InvalidDataException $ex) { } catch (InvalidDataException) {
return null;
}
return $newRefreshRate;
}
/**
* update subscription stored in database
* used to set:
* - refreshrate
* - source
*
* @param array $subscription
* @param array $mutations
*/
private function updateSubscription(array $subscription, array $mutations) {
if (empty($mutations)) {
return; return;
} }
$propPatch = new PropPatch($mutations); $propPatch = new PropPatch([self::REFRESH_RATE => $refreshRate]);
$this->calDavBackend->updateSubscription($subscription['id'], $propPatch); $this->calDavBackend->updateSubscription($subscription['id'], $propPatch);
$propPatch->commit(); $propPatch->commit();
} }
/**
* Returns a random uri for a calendar-object
*
* @return string
*/
public function getRandomCalendarObjectUri():string {
return UUIDUtil::getUUID() . '.ics';
}
private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool {
foreach ($vObject->getComponents() as $component) {
unset($component->{'DTSTAMP'});
}
$localVobject = Reader::read($calendarObject['calendardata']);
foreach ($localVobject->getComponents() as $component) {
unset($component->{'DTSTAMP'});
}
return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0;
}
} }

@ -89,12 +89,8 @@ class ConnectionTest extends TestCase {
} }
/**
* @param string $result
* @param string $contentType
*/
#[\PHPUnit\Framework\Attributes\DataProvider('urlDataProvider')] #[\PHPUnit\Framework\Attributes\DataProvider('urlDataProvider')]
public function testConnection(string $url, string $result, string $contentType): void { public function testConnection(string $url, string $contentType, string $expectedFormat): void {
$client = $this->createMock(IClient::class); $client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class); $response = $this->createMock(IResponse::class);
$subscription = [ $subscription = [
@ -123,16 +119,76 @@ class ConnectionTest extends TestCase {
->with('https://foo.bar/bla2') ->with('https://foo.bar/bla2')
->willReturn($response); ->willReturn($response);
$response->expects($this->once())
->method('getHeader')
->with('Content-Type')
->willReturn($contentType);
// Create a stream resource to simulate streaming response
$stream = fopen('php://temp', 'r+');
fwrite($stream, 'test calendar data');
rewind($stream);
$response->expects($this->once()) $response->expects($this->once())
->method('getBody') ->method('getBody')
->willReturn($stream);
$output = $this->connection->queryWebcalFeed($subscription);
$this->assertIsArray($output);
$this->assertArrayHasKey('data', $output);
$this->assertArrayHasKey('format', $output);
$this->assertIsResource($output['data']);
$this->assertEquals($expectedFormat, $output['format']);
// Cleanup
if (is_resource($output['data'])) {
fclose($output['data']);
}
}
public function testConnectionReturnsNullWhenBodyIsNotResource(): void {
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
$subscription = [
'id' => 42,
'uri' => 'sub123',
'refreshreate' => 'P1H',
'striptodos' => 1,
'stripalarms' => 1,
'stripattachments' => 1,
'source' => 'https://foo.bar/bla2',
'lastmodified' => 0,
];
$this->clientService->expects($this->once())
->method('newClient')
->with() ->with()
->willReturn($result); ->willReturn($client);
$this->config->expects($this->once())
->method('getValueString')
->with('dav', 'webcalAllowLocalAccess', 'no')
->willReturn('no');
$client->expects($this->once())
->method('get')
->with('https://foo.bar/bla2')
->willReturn($response);
$response->expects($this->once()) $response->expects($this->once())
->method('getHeader') ->method('getHeader')
->with('Content-Type') ->with('Content-Type')
->willReturn($contentType); ->willReturn('text/calendar');
$this->connection->queryWebcalFeed($subscription); // Return a string instead of a resource
$response->expects($this->once())
->method('getBody')
->willReturn('not a resource');
$output = $this->connection->queryWebcalFeed($subscription);
$this->assertNull($output);
} }
public static function runLocalURLDataProvider(): array { public static function runLocalURLDataProvider(): array {
@ -156,21 +212,9 @@ class ConnectionTest extends TestCase {
public static function urlDataProvider(): array { public static function urlDataProvider(): array {
return [ return [
[ ['https://foo.bar/bla2', 'text/calendar;charset=utf8', 'ical'],
'https://foo.bar/bla2', ['https://foo.bar/bla2', 'application/calendar+json', 'jcal'],
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", ['https://foo.bar/bla2', 'application/calendar+xml', 'xcal'],
'text/calendar;charset=utf8',
],
[
'https://foo.bar/bla2',
'["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]',
'application/calendar+json',
],
[
'https://foo.bar/bla2',
'<?xml version="1.0" encoding="utf-8" ?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><prodid><text>-//Example Inc.//Example Client//EN</text></prodid><version><text>2.0</text></version></properties><components><vevent><properties><dtstamp><date-time>2006-02-06T00:11:21Z</date-time></dtstamp><dtstart><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T14:00:00</date-time></dtstart><duration><duration>PT1H</duration></duration><recurrence-id><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T12:00:00</date-time></recurrence-id><summary><text>Event #2 bis</text></summary><uid><text>12345</text></uid></properties></vevent></components></vcalendar></icalendar>',
'application/calendar+xml',
],
]; ];
} }
} }

@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\DAV\Tests\unit\CalDAV\WebcalCaching; namespace OCA\DAV\Tests\unit\CalDAV\WebcalCaching;
use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Import\ImportService;
use OCA\DAV\CalDAV\WebcalCaching\Connection; use OCA\DAV\CalDAV\WebcalCaching\Connection;
use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService; use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService;
use OCP\AppFramework\Utility\ITimeFactory; use OCP\AppFramework\Utility\ITimeFactory;
@ -23,7 +24,8 @@ class RefreshWebcalServiceTest extends TestCase {
private CalDavBackend&MockObject $caldavBackend; private CalDavBackend&MockObject $caldavBackend;
private Connection&MockObject $connection; private Connection&MockObject $connection;
private LoggerInterface&MockObject $logger; private LoggerInterface&MockObject $logger;
private ITimeFactory&MockObject $time; private ImportService&MockObject $importService;
private ITimeFactory&MockObject $timeFactory;
protected function setUp(): void { protected function setUp(): void {
parent::setUp(); parent::setUp();
@ -31,19 +33,32 @@ class RefreshWebcalServiceTest extends TestCase {
$this->caldavBackend = $this->createMock(CalDavBackend::class); $this->caldavBackend = $this->createMock(CalDavBackend::class);
$this->connection = $this->createMock(Connection::class); $this->connection = $this->createMock(Connection::class);
$this->logger = $this->createMock(LoggerInterface::class); $this->logger = $this->createMock(LoggerInterface::class);
$this->time = $this->createMock(ITimeFactory::class); $this->importService = $this->createMock(ImportService::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
// Default time factory behavior: current time is far in the future so refresh always happens
$this->timeFactory->method('getTime')->willReturn(PHP_INT_MAX);
$this->timeFactory->method('getDateTime')->willReturn(new \DateTime());
} }
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] /**
public function testRun(string $body, string $contentType, string $result): void { * Helper to create a resource stream from string content
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) */
->onlyMethods(['getRandomCalendarObjectUri']) private function createStreamFromString(string $content) {
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) $stream = fopen('php://temp', 'r+');
->getMock(); fwrite($stream, $content);
rewind($stream);
return $stream;
}
$refreshWebcalService #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
->method('getRandomCalendarObjectUri') public function testRun(string $body, string $format, string $result): void {
->willReturn('uri-1.ics'); $refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser') ->method('getSubscriptionsForUser')
@ -71,26 +86,48 @@ class RefreshWebcalServiceTest extends TestCase {
], ],
]); ]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once()) $this->connection->expects(self::once())
->method('queryWebcalFeed') ->method('queryWebcalFeed')
->willReturn($result); ->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([]);
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('createCalendarObject') ->method('createCalendarObject')
->with(42, 'uri-1.ics', $result, 1); ->with(
'42',
self::matchesRegularExpression('/^[a-f0-9-]+\.ics$/'),
$result,
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
);
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
} }
#[\PHPUnit\Framework\Attributes\DataProvider('identicalDataProvider')] #[\PHPUnit\Framework\Attributes\DataProvider('identicalDataProvider')]
public function testRunIdentical(string $uid, array $calendarObject, string $body, string $contentType, string $result): void { public function testRunIdentical(string $uid, array $calendarObject, string $body, string $format, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) $refreshWebcalService = new RefreshWebcalService(
->onlyMethods(['getRandomCalendarObjectUri']) $this->caldavBackend,
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) $this->logger,
->getMock(); $this->connection,
$this->timeFactory,
$refreshWebcalService $this->importService
->method('getRandomCalendarObjectUri') );
->willReturn('uri-1.ics');
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser') ->method('getSubscriptionsForUser')
@ -118,78 +155,199 @@ class RefreshWebcalServiceTest extends TestCase {
], ],
]); ]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once()) $this->connection->expects(self::once())
->method('queryWebcalFeed') ->method('queryWebcalFeed')
->willReturn($result); ->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects') ->method('getLimitedCalendarObjects')
->willReturn($calendarObject); ->willReturn($calendarObject);
$denormalised = [ // Create a VCalendar object that will be yielded by the import service
'etag' => 100, $vCalendar = VObject\Reader::read($result);
'size' => strlen($calendarObject[$uid]['calendardata']),
'uid' => 'sub456'
];
$this->caldavBackend->expects(self::once()) $generator = function () use ($vCalendar) {
->method('getDenormalizedData') yield $vCalendar;
->willReturn($denormalised); };
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$this->caldavBackend->expects(self::never()) $this->caldavBackend->expects(self::never())
->method('createCalendarObject'); ->method('createCalendarObject');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub456'); $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
} }
public function testRunJustUpdated(): void { public function testSubscriptionNotFound(): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) $refreshWebcalService = new RefreshWebcalService(
->onlyMethods(['getRandomCalendarObjectUri']) $this->caldavBackend,
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) $this->logger,
->getMock(); $this->connection,
$this->timeFactory,
$this->importService
);
$refreshWebcalService $this->caldavBackend->expects(self::once())
->method('getRandomCalendarObjectUri') ->method('getSubscriptionsForUser')
->willReturn('uri-1.ics'); ->with('principals/users/testuser')
->willReturn([]);
$this->connection->expects(self::never())
->method('queryWebcalFeed');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testConnectionReturnsNull(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser') ->method('getSubscriptionsForUser')
->with('principals/users/testuser') ->with('principals/users/testuser')
->willReturn([ ->willReturn([
[ [
'id' => '99', 'id' => '42',
'uri' => 'sub456', 'uri' => 'sub123',
RefreshWebcalService::REFRESH_RATE => 'P1D',
RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla', 'source' => 'webcal://foo.bar/bla2',
'lastmodified' => time(), 'lastmodified' => 0,
], ],
]);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(null);
$this->importService->expects(self::never())
->method('importText');
$this->caldavBackend->expects(self::never())
->method('createCalendarObject');
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testDeletedObjectsArePurged(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[ [
'id' => '42', 'id' => '42',
'uri' => 'sub123', 'uri' => 'sub123',
RefreshWebcalService::REFRESH_RATE => 'PT1H',
RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla2', 'source' => 'webcal://foo.bar/bla2',
'lastmodified' => time(), 'lastmodified' => 0,
], ],
]); ]);
$timeMock = $this->createMock(\DateTime::class); $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:new-event\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:New Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$this->time->expects(self::once()) $stream = $this->createStreamFromString($body);
->method('getDateTime')
->willReturn($timeMock); $this->connection->expects(self::once())
$timeMock->expects(self::once()) ->method('queryWebcalFeed')
->method('getTimestamp') ->willReturn(['data' => $stream, 'format' => 'ical']);
->willReturn(2101724667);
$this->time->expects(self::once()) // Existing objects include one that won't be in the feed
->method('getTime') $this->caldavBackend->expects(self::once())
->willReturn(time()); ->method('getLimitedCalendarObjects')
$this->connection->expects(self::never()) ->willReturn([
->method('queryWebcalFeed'); 'old-deleted-event' => [
'id' => 99,
'uid' => 'old-deleted-event',
'etag' => 'old-etag',
'uri' => 'old-event.ics',
],
]);
$vCalendar = VObject\Reader::read($body);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$this->caldavBackend->expects(self::once())
->method('createCalendarObject');
$this->caldavBackend->expects(self::once())
->method('purgeCachedEventsForSubscription')
->with(42, [99], ['old-event.ics']);
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}
public function testLongUidIsSkipped(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);
$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[
'id' => '42',
'uri' => 'sub123',
RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla2',
'lastmodified' => 0,
],
]);
// Create a UID that is longer than 512 characters
$longUid = str_repeat('a', 513);
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:$longUid\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:Event with long UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(['data' => $stream, 'format' => 'ical']);
$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([]);
$vCalendar = VObject\Reader::read($body);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
// Event with long UID should be skipped, so createCalendarObject should never be called
$this->caldavBackend->expects(self::never()) $this->caldavBackend->expects(self::never())
->method('createCalendarObject'); ->method('createCalendarObject');
@ -197,16 +355,12 @@ class RefreshWebcalServiceTest extends TestCase {
} }
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
public function testRunCreateCalendarNoException(string $body, string $contentType, string $result): void { public function testRunCreateCalendarNoException(string $body, string $format, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription',]) ->onlyMethods(['getSubscription'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService])
->getMock(); ->getMock();
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
$refreshWebcalService $refreshWebcalService
->method('getSubscription') ->method('getSubscription')
->willReturn([ ->willReturn([
@ -220,13 +374,26 @@ class RefreshWebcalServiceTest extends TestCase {
'lastmodified' => 0, 'lastmodified' => 0,
]); ]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once()) $this->connection->expects(self::once())
->method('queryWebcalFeed') ->method('queryWebcalFeed')
->willReturn($result); ->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('createCalendarObject') ->method('getLimitedCalendarObjects')
->with(42, 'uri-1.ics', $result, 1); ->willReturn([]);
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$noInstanceException = new NoInstancesException("can't add calendar object"); $noInstanceException = new NoInstancesException("can't add calendar object");
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
@ -241,16 +408,12 @@ class RefreshWebcalServiceTest extends TestCase {
} }
#[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')] #[\PHPUnit\Framework\Attributes\DataProvider('runDataProvider')]
public function testRunCreateCalendarBadRequest(string $body, string $contentType, string $result): void { public function testRunCreateCalendarBadRequest(string $body, string $format, string $result): void {
$refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class)
->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription']) ->onlyMethods(['getSubscription'])
->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService])
->getMock(); ->getMock();
$refreshWebcalService
->method('getRandomCalendarObjectUri')
->willReturn('uri-1.ics');
$refreshWebcalService $refreshWebcalService
->method('getSubscription') ->method('getSubscription')
->willReturn([ ->willReturn([
@ -264,13 +427,26 @@ class RefreshWebcalServiceTest extends TestCase {
'lastmodified' => 0, 'lastmodified' => 0,
]); ]);
$stream = $this->createStreamFromString($body);
$this->connection->expects(self::once()) $this->connection->expects(self::once())
->method('queryWebcalFeed') ->method('queryWebcalFeed')
->willReturn($result); ->willReturn(['data' => $stream, 'format' => $format]);
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
->method('createCalendarObject') ->method('getLimitedCalendarObjects')
->with(42, 'uri-1.ics', $result, 1); ->willReturn([]);
// Create a VCalendar object that will be yielded by the import service
$vCalendar = VObject\Reader::read($result);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};
$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());
$badRequestException = new BadRequest("can't add reach calendar url"); $badRequestException = new BadRequest("can't add reach calendar url");
$this->caldavBackend->expects(self::once()) $this->caldavBackend->expects(self::once())
@ -285,20 +461,22 @@ class RefreshWebcalServiceTest extends TestCase {
} }
public static function identicalDataProvider(): array { public static function identicalDataProvider(): array {
$icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$etag = md5($icalBody);
return [ return [
[ [
'12345', '12345',
[ [
'12345' => [ '12345' => [
'id' => 42, 'id' => 42,
'etag' => 100, 'etag' => $etag,
'uri' => 'sub456', 'uri' => 'sub456.ics',
'calendardata' => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
], ],
], ],
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'text/calendar;charset=utf8', 'ical',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20180218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", $icalBody,
], ],
]; ];
} }
@ -307,19 +485,9 @@ class RefreshWebcalServiceTest extends TestCase {
return [ return [
[ [
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
'text/calendar;charset=utf8', 'ical',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
], ],
[
'["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]',
'application/calendar+json',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nLAST-MODIFIED:20040110T032845Z\r\nTZID:US/Eastern\r\nBEGIN:DAYLIGHT\r\nDTSTART:20000404T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\r\nTZNAME:EDT\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nDTSTART:20001026T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=10\r\nTZNAME:EST\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060102T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
],
[
'<?xml version="1.0" encoding="utf-8" ?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><prodid><text>-//Example Inc.//Example Client//EN</text></prodid><version><text>2.0</text></version></properties><components><vevent><properties><dtstamp><date-time>2006-02-06T00:11:21Z</date-time></dtstamp><dtstart><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T14:00:00</date-time></dtstart><duration><duration>PT1H</duration></duration><recurrence-id><parameters><tzid><text>US/Eastern</text></tzid></parameters><date-time>2006-01-04T12:00:00</date-time></recurrence-id><summary><text>Event #2 bis</text></summary><uid><text>12345</text></uid></properties></vevent></components></vcalendar></icalendar>',
'application/calendar+xml',
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060104T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2 bis\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
]
]; ];
} }
} }

@ -433,11 +433,6 @@
<code><![CDATA[!isset($newProps['filters']['props']) || !is_array($newProps['filters']['props'])]]></code> <code><![CDATA[!isset($newProps['filters']['props']) || !is_array($newProps['filters']['props'])]]></code>
</TypeDoesNotContainType> </TypeDoesNotContainType>
</file> </file>
<file src="apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php">
<InvalidArgument>
<code><![CDATA[$webcalData]]></code>
</InvalidArgument>
</file>
<file src="apps/dav/lib/CardDAV/AddressBookImpl.php"> <file src="apps/dav/lib/CardDAV/AddressBookImpl.php">
<InvalidArgument> <InvalidArgument>
<code><![CDATA[$this->getKey()]]></code> <code><![CDATA[$this->getKey()]]></code>