Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>tags/v29.0.0beta1
@@ -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, | |||
); | |||
} | |||
/** |
@@ -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; | |||
} | |||
@@ -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() |
@@ -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 { | |||
@@ -58,6 +67,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 | |||
* | |||
@@ -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); | |||
} | |||
} | |||
@@ -264,6 +322,30 @@ class CustomPropertiesBackend implements BackendInterface { | |||
$statement->closeCursor(); | |||
} | |||
/** | |||
* 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') |
@@ -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() |
@@ -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 |
@@ -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/')]], | |||
]; | |||
} | |||
@@ -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/" |
@@ -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 | |||
* | |||
@@ -105,6 +106,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 | |||
@@ -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, | |||
], | |||
] | |||
); | |||
} | |||
} |