aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/dav/lib/CalDAV/Schedule/Plugin.php7
-rw-r--r--apps/dav/lib/Connector/Sabre/Principal.php1
-rw-r--r--apps/dav/lib/Connector/Sabre/ServerFactory.php1
-rw-r--r--apps/dav/lib/DAV/CustomPropertiesBackend.php139
-rw-r--r--apps/dav/lib/Server.php1
-rw-r--r--apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php1
-rw-r--r--apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php200
-rw-r--r--build/integration/dav_features/caldav.feature10
-rw-r--r--build/integration/features/bootstrap/CalDavContext.php149
9 files changed, 500 insertions, 9 deletions
diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php
index 232ee607c94..2ccecc8131c 100644
--- a/apps/dav/lib/CalDAV/Schedule/Plugin.php
+++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php
@@ -95,6 +95,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
$server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90);
$server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']);
$server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
+
+ // We allow mutating the default calendar URL through the CustomPropertiesBackend
+ // (oc_properties table)
+ $server->protectedProperties = array_filter(
+ $server->protectedProperties,
+ static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL,
+ );
}
/**
diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php
index 9d9cfbe43cb..c6f9fe3affc 100644
--- a/apps/dav/lib/Connector/Sabre/Principal.php
+++ b/apps/dav/lib/Connector/Sabre/Principal.php
@@ -260,6 +260,7 @@ class Principal implements BackendInterface {
* @return int
*/
public function updatePrincipal($path, PropPatch $propPatch) {
+ // Updating schedule-default-calendar-URL is handled in CustomPropertiesBackend
return 0;
}
diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php
index 113cd8a8c23..758951c42ff 100644
--- a/apps/dav/lib/Connector/Sabre/ServerFactory.php
+++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php
@@ -188,6 +188,7 @@ class ServerFactory {
$server->addPlugin(
new \Sabre\DAV\PropertyStorage\Plugin(
new \OCA\DAV\DAV\CustomPropertiesBackend(
+ $server,
$objectTree,
$this->databaseConnection,
$this->userSession->getUser()
diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php
index e76a71aec63..48872048ea8 100644
--- a/apps/dav/lib/DAV/CustomPropertiesBackend.php
+++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php
@@ -6,6 +6,7 @@
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Robin Appelman <robin@icewind.nl>
* @author Thomas Müller <thomas.mueller@tmit.eu>
+ * @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0
*
@@ -31,11 +32,19 @@ use OCA\DAV\Connector\Sabre\FilesPlugin;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUser;
+use Sabre\CalDAV\ICalendar;
+use Sabre\DAV\Exception as DavException;
use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
+use Sabre\DAV\Server;
use Sabre\DAV\Tree;
use Sabre\DAV\Xml\Property\Complex;
+use Sabre\DAV\Xml\Property\Href;
+use Sabre\DAV\Xml\Property\LocalHref;
+use Sabre\Xml\ParseException;
+use Sabre\Xml\Service as XmlService;
+
use function array_intersect;
class CustomPropertiesBackend implements BackendInterface {
@@ -59,6 +68,11 @@ class CustomPropertiesBackend implements BackendInterface {
public const PROPERTY_TYPE_OBJECT = 3;
/**
+ * Value is stored as a {DAV:}href string.
+ */
+ public const PROPERTY_TYPE_HREF = 4;
+
+ /**
* Ignored properties
*
* @var string[]
@@ -105,6 +119,15 @@ class CustomPropertiesBackend implements BackendInterface {
*/
private const PUBLISHED_READ_ONLY_PROPERTIES = [
'{urn:ietf:params:xml:ns:caldav}calendar-availability',
+ '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
+ ];
+
+ /**
+ * Map of custom XML elements to parse when trying to deserialize an instance of
+ * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
+ */
+ private const COMPLEX_XML_ELEMENT_MAP = [
+ '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
];
/**
@@ -129,19 +152,29 @@ class CustomPropertiesBackend implements BackendInterface {
*/
private $userCache = [];
+ private Server $server;
+ private XmlService $xmlService;
+
/**
* @param Tree $tree node tree
* @param IDBConnection $connection database connection
* @param IUser $user owner of the tree and properties
*/
public function __construct(
+ Server $server,
Tree $tree,
IDBConnection $connection,
IUser $user,
) {
+ $this->server = $server;
$this->tree = $tree;
$this->connection = $connection;
$this->user = $user;
+ $this->xmlService = new XmlService();
+ $this->xmlService->elementMap = array_merge(
+ $this->xmlService->elementMap,
+ self::COMPLEX_XML_ELEMENT_MAP,
+ );
}
/**
@@ -199,6 +232,21 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
+ // substr of principals/users/ => path is a user principal
+ // two '/' => this a principal collection (and not some child object)
+ if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
+ $allRequestedProps = $propFind->getRequestedProperties();
+ $customProperties = [
+ '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
+ ];
+
+ foreach ($customProperties as $customProperty) {
+ if (in_array($customProperty, $allRequestedProps, true)) {
+ $requestedProps[] = $customProperty;
+ }
+ }
+ }
+
if (empty($requestedProps)) {
return;
}
@@ -211,9 +259,19 @@ class CustomPropertiesBackend implements BackendInterface {
// First fetch the published properties (set by another user), then get the ones set by
// the current user. If both are set then the latter as priority.
foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
+ try {
+ $this->validateProperty($path, $propName, $propValue);
+ } catch (DavException $e) {
+ continue;
+ }
$propFind->set($propName, $propValue);
}
foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
+ try {
+ $this->validateProperty($path, $propName, $propValue);
+ } catch (DavException $e) {
+ continue;
+ }
$propFind->set($propName, $propValue);
}
}
@@ -265,6 +323,30 @@ class CustomPropertiesBackend implements BackendInterface {
}
/**
+ * Validate the value of a property. Will throw if a value is invalid.
+ *
+ * @throws DavException The value of the property is invalid
+ */
+ private function validateProperty(string $path, string $propName, mixed $propValue): void {
+ switch ($propName) {
+ case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
+ /** @var Href $propValue */
+ $href = $propValue->getHref();
+ if ($href === null) {
+ throw new DavException('Href is empty');
+ }
+
+ // $path is the principal here as this prop is only set on principals
+ $node = $this->tree->getNodeForPath($href);
+ if (!($node instanceof ICalendar) || $node->getOwner() !== $path) {
+ throw new DavException('No such calendar');
+ }
+
+ break;
+ }
+ }
+
+ /**
* @param string $path
* @param string[] $requestedProperties
*
@@ -393,7 +475,11 @@ class CustomPropertiesBackend implements BackendInterface {
->executeStatement();
}
} else {
- [$value, $valueType] = $this->encodeValueForDatabase($propertyValue);
+ [$value, $valueType] = $this->encodeValueForDatabase(
+ $path,
+ $propertyName,
+ $propertyValue,
+ );
$dbParameters['propertyValue'] = $value;
$dbParameters['valueType'] = $valueType;
@@ -436,15 +522,38 @@ class CustomPropertiesBackend implements BackendInterface {
}
/**
- * @param mixed $value
- * @return array
+ * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
+ * @throws DavException If the property value is invalid
*/
- private function encodeValueForDatabase($value): array {
+ private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
+ // Try to parse a more specialized property type first
+ if ($value instanceof Complex) {
+ $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
+ $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
+ }
+
+ if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
+ $value = $this->encodeDefaultCalendarUrl($value);
+ }
+
+ try {
+ $this->validateProperty($path, $name, $value);
+ } catch (DavException $e) {
+ throw new DavException(
+ "Property \"$name\" has an invalid value: " . $e->getMessage(),
+ 0,
+ $e,
+ );
+ }
+
if (is_scalar($value)) {
$valueType = self::PROPERTY_TYPE_STRING;
} elseif ($value instanceof Complex) {
$valueType = self::PROPERTY_TYPE_XML;
$value = $value->getXml();
+ } elseif ($value instanceof Href) {
+ $valueType = self::PROPERTY_TYPE_HREF;
+ $value = $value->getHref();
} else {
$valueType = self::PROPERTY_TYPE_OBJECT;
$value = serialize($value);
@@ -459,6 +568,8 @@ class CustomPropertiesBackend implements BackendInterface {
switch ($valueType) {
case self::PROPERTY_TYPE_XML:
return new Complex($value);
+ case self::PROPERTY_TYPE_HREF:
+ return new Href($value);
case self::PROPERTY_TYPE_OBJECT:
return unserialize($value);
case self::PROPERTY_TYPE_STRING:
@@ -467,6 +578,26 @@ class CustomPropertiesBackend implements BackendInterface {
}
}
+ private function encodeDefaultCalendarUrl(Href $value): Href {
+ $href = $value->getHref();
+ if ($href === null) {
+ return $value;
+ }
+
+ if (!str_starts_with($href, '/')) {
+ return $value;
+ }
+
+ try {
+ // Build path relative to the dav base URI to be used later to find the node
+ $value = new LocalHref($this->server->calculateUri($href) . '/');
+ } catch (DavException\Forbidden) {
+ // Not existing calendars will be handled later when the value is validated
+ }
+
+ return $value;
+ }
+
private function createDeleteQuery(): IQueryBuilder {
$deleteQuery = $this->connection->getQueryBuilder();
$deleteQuery->delete('properties')
diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php
index 3c7e0936735..deee381d24c 100644
--- a/apps/dav/lib/Server.php
+++ b/apps/dav/lib/Server.php
@@ -276,6 +276,7 @@ class Server {
$this->server->addPlugin(
new \Sabre\DAV\PropertyStorage\Plugin(
new CustomPropertiesBackend(
+ $this->server,
$this->server->tree,
\OC::$server->getDatabaseConnection(),
\OC::$server->getUserSession()->getUser()
diff --git a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php
index 395c4a6a779..636fd0d2d8d 100644
--- a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php
+++ b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php
@@ -87,6 +87,7 @@ class CustomPropertiesBackendTest extends \Test\TestCase {
->willReturn($userId);
$this->plugin = new \OCA\DAV\DAV\CustomPropertiesBackend(
+ $this->server,
$this->tree,
\OC::$server->getDatabaseConnection(),
$this->user
diff --git a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php
index 2c8a55d3da1..b81d7f24ae4 100644
--- a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php
+++ b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php
@@ -8,6 +8,7 @@
* @author Morris Jobke <hey@morrisjobke.de>
* @author Robin Appelman <robin@icewind.nl>
* @author Roeland Jago Douma <roeland@famdouma.nl>
+ * @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@@ -28,17 +29,29 @@
namespace OCA\DAV\Tests\DAV;
use OCA\DAV\DAV\CustomPropertiesBackend;
+use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUser;
+use Sabre\CalDAV\ICalendar;
+use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
+use Sabre\DAV\Server;
use Sabre\DAV\Tree;
+use Sabre\DAV\Xml\Property\Href;
+use Sabre\DAVACL\IACL;
+use Sabre\DAVACL\IPrincipal;
use Test\TestCase;
/**
* @group DB
*/
class CustomPropertiesBackendTest extends TestCase {
+ private const BASE_URI = '/remote.php/dav/';
+
+ /** @var Server | \PHPUnit\Framework\MockObject\MockObject */
+ private $server;
+
/** @var Tree | \PHPUnit\Framework\MockObject\MockObject */
private $tree;
@@ -54,6 +67,9 @@ class CustomPropertiesBackendTest extends TestCase {
protected function setUp(): void {
parent::setUp();
+ $this->server = $this->createMock(Server::class);
+ $this->server->method('getBaseUri')
+ ->willReturn(self::BASE_URI);
$this->tree = $this->createMock(Tree::class);
$this->user = $this->createMock(IUser::class);
$this->user->method('getUID')
@@ -62,9 +78,10 @@ class CustomPropertiesBackendTest extends TestCase {
$this->dbConnection = \OC::$server->getDatabaseConnection();
$this->backend = new CustomPropertiesBackend(
+ $this->server,
$this->tree,
$this->dbConnection,
- $this->user
+ $this->user,
);
}
@@ -90,7 +107,13 @@ class CustomPropertiesBackendTest extends TestCase {
}
}
- protected function insertProp(string $user, string $path, string $name, string $value) {
+ protected function insertProp(string $user, string $path, string $name, mixed $value) {
+ $type = CustomPropertiesBackend::PROPERTY_TYPE_STRING;
+ if ($value instanceof Href) {
+ $value = $value->getHref();
+ $type = CustomPropertiesBackend::PROPERTY_TYPE_HREF;
+ }
+
$query = $this->dbConnection->getQueryBuilder();
$query->insert('properties')
->values([
@@ -98,13 +121,14 @@ class CustomPropertiesBackendTest extends TestCase {
'propertypath' => $query->createNamedParameter($this->formatPath($path)),
'propertyname' => $query->createNamedParameter($name),
'propertyvalue' => $query->createNamedParameter($value),
+ 'valuetype' => $query->createNamedParameter($type, IQueryBuilder::PARAM_INT)
]);
$query->execute();
}
protected function getProps(string $user, string $path) {
$query = $this->dbConnection->getQueryBuilder();
- $query->select('propertyname', 'propertyvalue')
+ $query->select('propertyname', 'propertyvalue', 'valuetype')
->from('properties')
->where($query->expr()->eq('userid', $query->createNamedParameter($user)))
->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($this->formatPath($path))));
@@ -112,7 +136,11 @@ class CustomPropertiesBackendTest extends TestCase {
$result = $query->execute();
$data = [];
while ($row = $result->fetch()) {
- $data[$row['propertyname']] = $row['propertyvalue'];
+ $value = $row['propertyvalue'];
+ if ((int)$row['valuetype'] === CustomPropertiesBackend::PROPERTY_TYPE_HREF) {
+ $value = new Href($value);
+ }
+ $data[$row['propertyname']] = $value;
}
$result->closeCursor();
@@ -122,9 +150,10 @@ class CustomPropertiesBackendTest extends TestCase {
public function testPropFindNoDbCalls(): void {
$db = $this->createMock(IDBConnection::class);
$backend = new CustomPropertiesBackend(
+ $this->server,
$this->tree,
$db,
- $this->user
+ $this->user,
);
$propFind = $this->createMock(PropFind::class);
@@ -186,10 +215,169 @@ class CustomPropertiesBackendTest extends TestCase {
$this->assertEquals($props, $setProps);
}
+ public function testPropFindPrincipalCall(): void {
+ $this->tree->method('getNodeForPath')
+ ->willReturnCallback(function ($uri) {
+ $node = $this->createMock(ICalendar::class);
+ $node->method('getOwner')
+ ->willReturn('principals/users/dummy_user_42');
+ return $node;
+ });
+
+ $propFind = $this->createMock(PropFind::class);
+ $propFind->method('get404Properties')
+ ->with()
+ ->willReturn([
+ '{DAV:}getcontentlength',
+ '{DAV:}getcontenttype',
+ '{DAV:}getetag',
+ '{abc}def',
+ ]);
+
+ $propFind->method('getRequestedProperties')
+ ->with()
+ ->willReturn([
+ '{DAV:}getcontentlength',
+ '{DAV:}getcontenttype',
+ '{DAV:}getetag',
+ '{abc}def',
+ '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
+ ]);
+
+ $props = [
+ '{abc}def' => 'a',
+ '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/admin/personal'),
+ ];
+ $this->insertProps('dummy_user_42', 'principals/users/dummy_user_42', $props);
+
+ $setProps = [];
+ $propFind->method('set')
+ ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
+ $setProps[$name] = $value;
+ });
+
+ $this->backend->propFind('principals/users/dummy_user_42', $propFind);
+ $this->assertEquals($props, $setProps);
+ }
+
+ public function propFindPrincipalScheduleDefaultCalendarProviderUrlProvider(): array {
+ // [ user, nodes, existingProps, requestedProps, returnedProps ]
+ return [
+ [ // Exists
+ 'dummy_user_42',
+ ['calendars/dummy_user_42/foo/' => ICalendar::class],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')],
+ ],
+ [ // Doesn't exist
+ 'dummy_user_42',
+ ['calendars/dummy_user_42/foo/' => ICalendar::class],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/bar/')],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
+ [],
+ ],
+ [ // No privilege
+ 'dummy_user_42',
+ ['calendars/user2/baz/' => ICalendar::class],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/user2/baz/')],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
+ [],
+ ],
+ [ // Not a calendar
+ 'dummy_user_42',
+ ['foo/dummy_user_42/bar/' => IACL::class],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/dummy_user_42/bar/')],
+ ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'],
+ [],
+ ],
+ ];
+
+ }
+
+ /**
+ * @dataProvider propFindPrincipalScheduleDefaultCalendarProviderUrlProvider
+ */
+ public function testPropFindPrincipalScheduleDefaultCalendarUrl(
+ string $user,
+ array $nodes,
+ array $existingProps,
+ array $requestedProps,
+ array $returnedProps,
+ ): void {
+ $propFind = $this->createMock(PropFind::class);
+ $propFind->method('get404Properties')
+ ->with()
+ ->willReturn([
+ '{DAV:}getcontentlength',
+ '{DAV:}getcontenttype',
+ '{DAV:}getetag',
+ ]);
+
+ $propFind->method('getRequestedProperties')
+ ->with()
+ ->willReturn(array_merge([
+ '{DAV:}getcontentlength',
+ '{DAV:}getcontenttype',
+ '{DAV:}getetag',
+ '{abc}def',
+ ],
+ $requestedProps,
+ ));
+
+ $this->server->method('calculateUri')
+ ->willReturnCallback(function ($uri) {
+ if (!str_starts_with($uri, self::BASE_URI)) {
+ return trim(substr($uri, strlen(self::BASE_URI)), '/');
+ }
+ return null;
+ });
+ $this->tree->method('getNodeForPath')
+ ->willReturnCallback(function ($uri) use ($nodes) {
+ if (str_starts_with($uri, 'principals/')) {
+ return $this->createMock(IPrincipal::class);
+ }
+ if (array_key_exists($uri, $nodes)) {
+ $owner = explode('/', $uri)[1];
+ $node = $this->createMock($nodes[$uri]);
+ $node->method('getOwner')
+ ->willReturn("principals/users/$owner");
+ return $node;
+ }
+ throw new NotFound('Node not found');
+ });
+
+ $this->insertProps($user, "principals/users/$user", $existingProps);
+
+ $setProps = [];
+ $propFind->method('set')
+ ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void {
+ $setProps[$name] = $value;
+ });
+
+ $this->backend->propFind("principals/users/$user", $propFind);
+ $this->assertEquals($returnedProps, $setProps);
+ }
+
/**
* @dataProvider propPatchProvider
*/
public function testPropPatch(string $path, array $existing, array $props, array $result): void {
+ $this->server->method('calculateUri')
+ ->willReturnCallback(function ($uri) {
+ if (str_starts_with($uri, self::BASE_URI)) {
+ return trim(substr($uri, strlen(self::BASE_URI)), '/');
+ }
+ return null;
+ });
+ $this->tree->method('getNodeForPath')
+ ->willReturnCallback(function ($uri) {
+ $node = $this->createMock(ICalendar::class);
+ $node->method('getOwner')
+ ->willReturn('principals/users/' . $this->user->getUID());
+ return $node;
+ });
+
$this->insertProps($this->user->getUID(), $path, $existing);
$propPatch = new PropPatch($props);
@@ -207,6 +395,8 @@ class CustomPropertiesBackendTest extends TestCase {
['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => null], []],
[$longPath, [], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']],
+ ['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]],
+ ['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href(self::BASE_URI . 'foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]],
];
}
diff --git a/build/integration/dav_features/caldav.feature b/build/integration/dav_features/caldav.feature
index fffdd89d367..f7baf76d4bc 100644
--- a/build/integration/dav_features/caldav.feature
+++ b/build/integration/dav_features/caldav.feature
@@ -75,3 +75,13 @@ Feature: caldav
Then The CalDAV HTTP status code should be "404"
And The exception is "Sabre\DAV\Exception\NotFound"
And The error message is "Node with name 'admin' could not be found"
+
+ Scenario: Update a principal's schedule-default-calendar-URL
+ Given user "user0" exists
+ And "user0" creates a calendar named "MyCalendar2"
+ When "user0" updates property "{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL" to href "/remote.php/dav/calendars/user0/MyCalendar2/" of principal "users/user0" on the endpoint "/remote.php/dav/principals/"
+ Then The CalDAV response should be multi status
+ And The CalDAV response should contain a property "{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL"
+ When "user0" requests principal "users/user0" on the endpoint "/remote.php/dav/principals/"
+ Then The CalDAV response should be multi status
+ And The CalDAV response should contain a property "{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL" with a href value "/remote.php/dav/calendars/user0/MyCalendar2/"
diff --git a/build/integration/features/bootstrap/CalDavContext.php b/build/integration/features/bootstrap/CalDavContext.php
index 936463b579e..bad0d915491 100644
--- a/build/integration/features/bootstrap/CalDavContext.php
+++ b/build/integration/features/bootstrap/CalDavContext.php
@@ -8,6 +8,7 @@
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Phil Davis <phil.davis@inf.org>
* @author Robin Appelman <robin@icewind.nl>
+ * @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0
*
@@ -106,6 +107,119 @@ class CalDavContext implements \Behat\Behat\Context\Context {
}
/**
+ * @When :user requests principal :principal on the endpoint :endpoint
+ */
+ public function requestsPrincipal(string $user, string $principal, string $endpoint): void {
+ $davUrl = $this->baseUrl . $endpoint . $principal;
+
+ $password = ($user === 'admin') ? 'admin' : '123456';
+ try {
+ $this->response = $this->client->request(
+ 'PROPFIND',
+ $davUrl,
+ [
+ 'headers' => [
+ 'Content-Type' => 'application/xml; charset=UTF-8',
+ 'Depth' => 0,
+ ],
+ 'body' => '<x0:propfind xmlns:x0="DAV:"><x0:prop><x0:displayname/><x1:calendar-user-type xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:calendar-user-address-set xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x0:principal-URL/><x0:alternate-URI-set/><x2:email-address xmlns:x2="http://sabredav.org/ns"/><x3:language xmlns:x3="http://nextcloud.com/ns"/><x1:calendar-home-set xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:schedule-inbox-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:schedule-outbox-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x1:schedule-default-calendar-URL xmlns:x1="urn:ietf:params:xml:ns:caldav"/><x3:resource-type xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-type xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-make xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-model xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-is-electric xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-range xmlns:x3="http://nextcloud.com/ns"/><x3:resource-vehicle-seating-capacity xmlns:x3="http://nextcloud.com/ns"/><x3:resource-contact-person xmlns:x3="http://nextcloud.com/ns"/><x3:resource-contact-person-vcard xmlns:x3="http://nextcloud.com/ns"/><x3:room-type xmlns:x3="http://nextcloud.com/ns"/><x3:room-seating-capacity xmlns:x3="http://nextcloud.com/ns"/><x3:room-building-address xmlns:x3="http://nextcloud.com/ns"/><x3:room-building-story xmlns:x3="http://nextcloud.com/ns"/><x3:room-building-room-number xmlns:x3="http://nextcloud.com/ns"/><x3:room-features xmlns:x3="http://nextcloud.com/ns"/><x0:principal-collection-set/><x0:supported-report-set/></x0:prop></x0:propfind>',
+ 'auth' => [
+ $user,
+ $password,
+ ],
+ ]
+ );
+ } catch (\GuzzleHttp\Exception\ClientException $e) {
+ $this->response = $e->getResponse();
+ }
+ }
+
+ /**
+ * @Then The CalDAV response should contain a property :key
+ * @throws \Exception
+ */
+ public function theCaldavResponseShouldContainAProperty(string $key): void {
+ /** @var \Sabre\DAV\Xml\Response\MultiStatus $multiStatus */
+ $multiStatus = $this->responseXml['value'];
+ $responses = $multiStatus->getResponses()[0]->getResponseProperties();
+ if (!isset($responses[200])) {
+ throw new \Exception(
+ sprintf(
+ 'Expected code 200 got [%s]',
+ implode(',', array_keys($responses)),
+ )
+ );
+ }
+
+ $props = $responses[200];
+ if (!array_key_exists($key, $props)) {
+ throw new \Exception(
+ sprintf(
+ 'Expected property %s in %s',
+ $key,
+ json_encode($props, JSON_PRETTY_PRINT),
+ )
+ );
+ }
+ }
+
+ /**
+ * @Then The CalDAV response should contain a property :key with a href value :value
+ * @throws \Exception
+ */
+ public function theCaldavResponseShouldContainAPropertyWithHrefValue(
+ string $key,
+ string $value,
+ ): void {
+ /** @var \Sabre\DAV\Xml\Response\MultiStatus $multiStatus */
+ $multiStatus = $this->responseXml['value'];
+ $responses = $multiStatus->getResponses()[0]->getResponseProperties();
+ if (!isset($responses[200])) {
+ throw new \Exception(
+ sprintf(
+ 'Expected code 200 got [%s]',
+ implode(',', array_keys($responses)),
+ )
+ );
+ }
+
+ $props = $responses[200];
+ if (!array_key_exists($key, $props)) {
+ throw new \Exception("Cannot find property \"$key\"");
+ }
+
+ $actualValue = $props[$key]->getHref();
+ if ($actualValue !== $value) {
+ throw new \Exception("Property \"$key\" found with value \"$actualValue\", expected \"$value\"");
+ }
+ }
+
+ /**
+ * @Then The CalDAV response should be multi status
+ * @throws \Exception
+ */
+ public function theCaldavResponseShouldBeMultiStatus(): void {
+ if (207 !== $this->response->getStatusCode()) {
+ throw new \Exception(
+ sprintf(
+ 'Expected code 207 got %s',
+ $this->response->getStatusCode()
+ )
+ );
+ }
+
+ $body = $this->response->getBody()->getContents();
+ if ($body && substr($body, 0, 1) === '<') {
+ $reader = new Sabre\Xml\Reader();
+ $reader->xml($body);
+ $reader->elementMap['{DAV:}multistatus'] = \Sabre\DAV\Xml\Response\MultiStatus::class;
+ $reader->elementMap['{DAV:}response'] = \Sabre\DAV\Xml\Element\Response::class;
+ $reader->elementMap['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'] = \Sabre\DAV\Xml\Property\Href::class;
+ $this->responseXml = $reader->parse();
+ }
+ }
+
+ /**
* @Then The CalDAV HTTP status code should be :code
* @param int $code
* @throws \Exception
@@ -258,4 +372,39 @@ class CalDavContext implements \Behat\Behat\Context\Context {
$this->response = $e->getResponse();
}
}
+
+ /**
+ * @Given :user updates property :key to href :value of principal :principal on the endpoint :endpoint
+ */
+ public function updatesHrefPropertyOfPrincipal(
+ string $user,
+ string $key,
+ string $value,
+ string $principal,
+ string $endpoint,
+ ): void {
+ $davUrl = $this->baseUrl . $endpoint . $principal;
+ $password = ($user === 'admin') ? 'admin' : '123456';
+
+ $propPatch = new \Sabre\DAV\Xml\Request\PropPatch();
+ $propPatch->properties = [$key => new \Sabre\DAV\Xml\Property\Href($value)];
+
+ $xml = new \Sabre\Xml\Service();
+ $body = $xml->write('{DAV:}propertyupdate', $propPatch, '/');
+
+ $this->response = $this->client->request(
+ 'PROPPATCH',
+ $davUrl,
+ [
+ 'headers' => [
+ 'Content-Type' => 'application/xml; charset=UTF-8',
+ ],
+ 'body' => $body,
+ 'auth' => [
+ $user,
+ $password,
+ ],
+ ]
+ );
+ }
}