diff options
-rw-r--r-- | apps/dav/lib/CalDAV/Schedule/Plugin.php | 7 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/Principal.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/ServerFactory.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/DAV/CustomPropertiesBackend.php | 139 | ||||
-rw-r--r-- | apps/dav/lib/Server.php | 1 | ||||
-rw-r--r-- | apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php | 1 | ||||
-rw-r--r-- | apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php | 200 | ||||
-rw-r--r-- | build/integration/dav_features/caldav.feature | 10 | ||||
-rw-r--r-- | build/integration/features/bootstrap/CalDavContext.php | 149 |
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, + ], + ] + ); + } } |