diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php index 21f60c6f..cef28caf 100644 --- a/src/app/Backends/DAV.php +++ b/src/app/Backends/DAV.php @@ -1,588 +1,648 @@ 'urn:ietf:params:xml:ns:caldav', self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav', self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav', ]; protected $url; protected $user; protected $password; protected $responseHeaders = []; protected $homes; /** * Object constructor */ public function __construct($user, $password, $url = null) { $this->url = $url ?: \config('services.dav.uri'); $this->user = $user; $this->password = $password; } /** * Discover DAV home (root) collection of a specified type. * * @return array|false Home locations or False on error */ public function discover() { if (is_array($this->homes)) { return $this->homes; } $path = parse_url($this->url, PHP_URL_PATH); $body = '' . '' . '' . '' . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { \Log::error("Failed to get current-user-principal from the DAV server."); return false; } $elements = $response->getElementsByTagName('response'); $principal_href = ''; foreach ($elements as $element) { foreach ($element->getElementsByTagName('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && str_starts_with($principal_href, $path)) { $principal_href = substr($principal_href, strlen($path)); } $ns = [ 'xmlns:d="DAV:"', 'xmlns:cal="urn:ietf:params:xml:ns:caldav"', 'xmlns:card="urn:ietf:params:xml:ns:carddav"', ]; $body = '' . '' . '' . '' . '' . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { \Log::error("Failed to get home collections from the DAV server."); return false; } $elements = $response->getElementsByTagName('response'); $homes = []; if ($element = $response->getElementsByTagName('response')->item(0)) { if ($prop = $element->getElementsByTagName('prop')->item(0)) { foreach ($prop->childNodes as $home) { if ($home->firstChild && $home->firstChild->localName == 'href') { $href = $home->firstChild->nodeValue; if ($path && str_starts_with($href, $path)) { $href = substr($href, strlen($path)); } $homes[$home->localName] = $href; } } } } return $this->homes = $homes; } /** * Get user home folder of specified type * * @param string $type Home type or component name * * @return string|null Folder location href */ public function getHome($type) { $options = [ self::TYPE_VEVENT => 'calendar-home-set', self::TYPE_VTODO => 'calendar-home-set', self::TYPE_VCARD => 'addressbook-home-set', self::TYPE_NOTIFICATION => 'notification-URL', ]; $homes = $this->discover(); if (is_array($homes) && isset($options[$type])) { return $homes[$options[$type]] ?? null; } return null; } /** * Check if we can connect to the DAV server * * @return bool True on success, False otherwise */ public static function healthcheck(): bool { // TODO return true; } /** * Get list of folders of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return false|array List of folders' metadata or False on error */ public function listFolders(string $component) { $root_href = $this->getHome($component); if ($root_href === null) { return false; } $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; $props = ''; if ($component != self::TYPE_VCARD) { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; $props = '' . '' . ''; } $body = '' . '' . '' . '' . '' . '' . $props . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; $response = $this->request($root_href, 'PROPFIND', $body, $headers); if (empty($response)) { \Log::error("Failed to get folders list from the DAV server."); return false; } $folders = []; foreach ($response->getElementsByTagName('response') as $element) { $folder = DAV\Folder::fromDomElement($element); // Note: Addressbooks don't have 'type' specified if ( ($component == self::TYPE_VCARD && in_array('addressbook', $folder->types)) || in_array($component, $folder->components) ) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder * * @param DAV\CommonObject $object Object * * @return false|DAV\CommonObject Object on success, False on error */ public function create(DAV\CommonObject $object) { $headers = ['Content-Type' => $object->contentType]; $content = (string) $object; if (!strlen($content)) { throw new \Exception("Cannot PUT an empty DAV object"); } $response = $this->request($object->href, 'PUT', $content, $headers); if ($response !== false) { if (!empty($this->responseHeaders['ETag'])) { $etag = $this->responseHeaders['ETag'][0]; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } $object->etag = $etag; } return $object; } return false; } /** * Update a DAV object in a folder * * @param DAV\CommonObject $object Object * * @return false|DAV\CommonObject Object on success, False on error */ public function update(DAV\CommonObject $object) { return $this->create($object); } /** * Delete a DAV object from a folder * * @param string $location Object location * * @return bool True on success, False on error */ public function delete(string $location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } /** * Create a DAV folder (collection) * * @param DAV\Folder $folder Folder object * * @return bool True on success, False on error */ public function folderCreate(DAV\Folder $folder) { $response = $this->request($folder->href, 'MKCOL', $folder->toXML('mkcol')); return $response !== false; } /** * Delete a DAV folder (collection) * * @param string $location Folder location * * @return bool True on success, False on error */ public function folderDelete($location) { $response = $this->request($location, 'DELETE'); return $response !== false; } /** * Get all properties of a folder. * * @param string $location Object location * * @return false|DAV\Folder Folder metadata or False on error */ public function folderInfo(string $location) { $body = DAV\Folder::propfindXML(); // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']); if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) { return DAV\Folder::fromDomElement($element); } return false; } /** * Update a DAV folder (collection) * * @param DAV\Folder $folder Folder object * * @return bool True on success, False on error */ public function folderUpdate(DAV\Folder $folder) { // Note: Changing resourcetype property is forbidden (at least by Cyrus) $response = $this->request($folder->href, 'PROPPATCH', $folder->toXML('propertyupdate')); return $response !== false; } + /** + * Initialize default DAV folders (collections) + * + * @param \App\User $user User object + * + * @throws \Exception + */ + public static function initDefaultFolders(\App\User $user): void + { + if (!\config('services.dav.uri')) { + return; + } + + $folders = \config('services.dav.default_folders'); + if (!count($folders)) { + return; + } + + // Cyrus DAV does not support proxy authorization via DAV. Even though it has + // the Authorize-As header, it is used only for cummunication with Murder backends. + // We use a one-time token instead. It's valid for 10 seconds, assume it's enough time. + $password = \App\Auth\Utils::tokenCreate((string) $user->id); + + if ($password === null) { + throw new \Exception("Failed to create an authentication token for DAV"); + } + + $dav = new self($user->email, $password); + + foreach ($folders as $props) { + $folder = new DAV\Folder(); + $folder->href = $props['type'] . 's' . '/user/' . $user->email . '/' . $props['path']; + $folder->types = ['collection', $props['type']]; + $folder->name = $props['displayname'] ?? ''; + $folder->components = $props['components'] ?? []; + + $existing = null; + try { + $existing = $dav->folderInfo($folder->href); + } catch (RequestException $e) { + // Cyrus DAV returns 503 Service Unavailable on a non-existing location (?) + if ($e->getCode() != 503 && $e->getCode() != 404) { + throw $e; + } + } + + // folder already exists? check the properties and update if needed + if ($existing) { + if ($existing->name != $folder->name || $existing->components != $folder->components) { + if (!$dav->folderUpdate($folder)) { + throw new \Exception("Failed to update DAV folder {$folder->href}"); + } + } + } elseif (!$dav->folderCreate($folder)) { + throw new \Exception("Failed to create DAV folder {$folder->href}"); + } + } + } + /** * Check server options (and authentication) * * @return false|array DAV capabilities on success, False on error */ public function options() { $response = $this->request('', 'OPTIONS'); if ($response !== false) { return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? [])); } return false; } /** * Search DAV objects in a folder. * * @param string $location Folder location * @param DAV\Search $search Search request parameters * @param callable $callback A callback to execute on every item * * @return false|array List of objects on success, False on error */ public function search(string $location, DAV\Search $search, $callback = null) { $headers = ['Depth' => $search->depth, 'Prefer' => 'return-minimal']; $response = $this->request($location, 'REPORT', $search, $headers); if (empty($response)) { \Log::error("Failed to get objects from the DAV server."); return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $object = $this->objectFromElement($element, $search->component); if ($callback) { $object = $callback($object); } if ($object) { if (is_array($object)) { $objects[$object[0]] = $object[1]; } else { $objects[] = $object; } } } return $objects; } /** * Fetch DAV objects data from a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * @param array $hrefs List of objects' locations to fetch * * @return false|array Objects metadata on success, False on error */ public function getObjects(string $location, string $component, array $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '' . $href . ''; } $queries = [ self::TYPE_VEVENT => 'calendar-multiget', self::TYPE_VTODO => 'calendar-multiget', self::TYPE_VCARD => 'addressbook-multiget', ]; $types = [ self::TYPE_VEVENT => 'calendar-data', self::TYPE_VTODO => 'calendar-data', self::TYPE_VCARD => 'address-data', ]; $body = '' . ' ' . '' . '' . '' . '' . $body . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { \Log::error("Failed to get objects from the DAV server."); return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->objectFromElement($element, $component); } return $objects; } /** * Parse XML content */ protected function parseXML($xml) { $doc = new \DOMDocument('1.0', 'UTF-8'); if (str_starts_with($xml, 'loadXML($xml)) { throw new \Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { if (is_array($header_value)) { $header_value = implode("\n\t", $header_value); } $head .= "{$header_name}: {$header_value}\n"; } if ($body instanceof DAV\CommonObject) { $body = (string) $body; } if (str_starts_with($body, 'formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new \Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : ''); } /** * Create DAV\CommonObject from a DOMElement */ protected function objectFromElement($element, $component) { switch ($component) { case self::TYPE_VEVENT: $object = DAV\Vevent::fromDomElement($element); break; case self::TYPE_VTODO: $object = DAV\Vtodo::fromDomElement($element); break; case self::TYPE_VCARD: $object = DAV\Vcard::fromDomElement($element); break; default: throw new \Exception("Unknown component: {$component}"); } return $object; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $debug = \config('app.debug'); $url = $this->url; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) { $path = substr($path, strlen($rootPath)); } $url .= $path; $client = Http::withBasicAuth($this->user, $this->password) // ->withToken($token) // Bearer token ->withOptions(['verify' => \config('services.dav.verify')]); if ($body) { if (!isset($headers['Content-Type'])) { $headers['Content-Type'] = 'application/xml; charset=utf-8'; } $client->withBody($body, $headers['Content-Type']); } if (!empty($headers)) { $client->withHeaders($headers); } if ($debug) { $body = $this->debugBody($body, $headers); \Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : '')); } $response = $client->send($method, $url); $body = $response->body(); $code = $response->status(); if ($debug) { \Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers())); } // Throw an exception if a client or server error occurred... $response->throw(); $this->responseHeaders = $response->headers(); return $this->parseXML($body); } } diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php index c86c6f08..dba51da0 100644 --- a/src/app/Backends/DAV/Folder.php +++ b/src/app/Backends/DAV/Folder.php @@ -1,155 +1,160 @@ getElementsByTagName('href')->item(0)) { $folder->href = $href->nodeValue; } if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) { $folder->color = substr($color->nodeValue, 1); } } if ($name = $element->getElementsByTagName('displayname')->item(0)) { $folder->name = $name->nodeValue; } if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { $folder->ctag = $ctag->nodeValue; } $components = []; if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { foreach ($set_element->getElementsByTagName('comp') as $comp) { $components[] = $comp->attributes->getNamedItem('name')->nodeValue; } } $types = []; if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { foreach ($type_element->childNodes as $node) { if ($node->nodeType == XML_ELEMENT_NODE) { $_type = explode(':', $node->nodeName); $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; } } } $folder->types = $types; $folder->components = $components; return $folder; } /** * Parse folder properties input into XML string to use in a request * * @return string */ public function toXML($tag) { $ns = 'xmlns:d="DAV:"'; $props = ''; $type = null; if (in_array('addressbook', $this->types)) { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"'; $type = 'addressbook'; } elseif (in_array('calendar', $this->types)) { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"'; $type = 'calendar'; } - $props .= '' . ($type ? "" : '') . ''; + // Cyrus DAV does not allow resourcetype property change + if ($tag != 'propertyupdate') { + $props .= '' . ($type ? "" : '') . ''; + } if (!empty($this->components)) { - $props .= ''; + // Note: Normally Cyrus DAV does not allow supported-calendar-component-set property update, + // but I found in Cyrus code that the action can be forced with force=yes attribute. + $props .= ''; foreach ($this->components as $component) { $props .= ''; } $props .= ''; } if ($this->name !== null) { $props .= '' . htmlspecialchars($this->name, ENT_XML1, 'UTF-8') . ''; } if ($this->color !== null) { $color = $this->color; if (strlen($color) && $color[0] != '#') { $color = '#' . $color; } $ns .= ' xmlns:a="http://apple.com/ns/ical/"'; $props .= '' . htmlspecialchars($color, ENT_XML1, 'UTF-8') . ''; } return '' . "{$props}"; } /** * Get XML string for PROPFIND query on a folder * * @return string */ public static function propfindXML() { $ns = implode(' ', [ 'xmlns:d="DAV:"', // 'xmlns:cs="http://calendarserver.org/ns/"', 'xmlns:c="urn:ietf:params:xml:ns:caldav"', // 'xmlns:a="http://apple.com/ns/ical/"', // 'xmlns:k="Kolab:"' ]); // Note: does not include some of the properties we're interested in return '' . '' . '' // . '' . '' // . '' // . '' // . '' . '' . '' // . '' . '' . ''; } } diff --git a/src/app/Backends/Helper.php b/src/app/Backends/Helper.php new file mode 100644 index 00000000..d850b8d0 --- /dev/null +++ b/src/app/Backends/Helper.php @@ -0,0 +1,55 @@ + 'Default', + 'displayname' => 'Calendar', + 'components' => ['VEVENT'], + 'type' => 'calendar', + ], + [ + 'path' => 'Tasks', + 'displayname' => 'Tasks', + 'components' => ['VTODO'], + 'type' => 'calendar', + ], + [ + // FIXME: Same here, should we use 'Contacts'? + 'path' => 'Default', + 'displayname' => 'Contacts', + 'type' => 'addressbook', + ], + ]; + } + + /** + * List of default IMAP folders + */ + public static function defaultImapFolders(): array + { + // TODO: Move the list from config/imap.php + return []; + } +} diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php index 16d09b85..08b7da93 100644 --- a/src/app/Jobs/User/CreateJob.php +++ b/src/app/Jobs/User/CreateJob.php @@ -1,120 +1,123 @@ isDeleted()`), or * * the user is actually deleted (`$user->deleted_at`), or * * the user is already marked as ready in LDAP (`$user->isLdapReady()`). */ class CreateJob extends UserJob { /** @var int Enable waiting for a user record to exist */ protected $waitForUser = 5; /** * Execute the job. * * @return void * * @throws \Exception */ public function handle() { $user = $this->getUser(); if (!$user) { return; } if ($user->role) { // Admins/resellers don't reside in LDAP (for now) return; } if ($user->email == \config('imap.admin_login')) { // Ignore Cyrus admin account return; } // sanity checks if ($user->isDeleted()) { $this->fail(new \Exception("User {$this->userId} is marked as deleted.")); return; } if ($user->trashed()) { $this->fail(new \Exception("User {$this->userId} is actually deleted.")); return; } $withLdap = \config('app.with_ldap'); // see if the domain is ready $domain = $user->domain(); if (!$domain) { $this->fail(new \Exception("The domain for {$this->userId} does not exist.")); return; } if ($domain->isDeleted()) { $this->fail(new \Exception("The domain for {$this->userId} is marked as deleted.")); return; } if ($withLdap && !$domain->isLdapReady()) { $this->release(60); return; } if (\config('abuse.suspend_enabled') && !$user->isSuspended()) { $code = \Artisan::call("user:abuse-check {$this->userId}"); if ($code == 2) { \Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}"); \App\EventLog::createFor($user, \App\EventLog::TYPE_SUSPENDED, "Suspected spammer"); $user->status |= \App\User::STATUS_SUSPENDED; } } if ($withLdap && !$user->isLdapReady()) { \App\Backends\LDAP::createUser($user); $user->status |= \App\User::STATUS_LDAP_READY; $user->save(); } if (!$user->isImapReady()) { if (\config('app.with_imap')) { if (!\App\Backends\IMAP::createUser($user)) { throw new \Exception("Failed to create mailbox for user {$this->userId}."); } } else { if (!\App\Backends\IMAP::verifyAccount($user->email)) { $this->release(15); return; } } $user->status |= \App\User::STATUS_IMAP_READY; } + // FIXME: Should we ignore exceptions on this operation or introduce DAV_READY status? + \App\Backends\DAV::initDefaultFolders($user); + // Make user active in non-mandate mode only if ( !($wallet = $user->wallet()) || !($plan = $user->wallet()->plan()) || $plan->mode != \App\Plan::MODE_MANDATE ) { $user->status |= \App\User::STATUS_ACTIVE; } $user->save(); } } diff --git a/src/config/services.php b/src/config/services.php index d84bcaf1..d9b2f134 100644 --- a/src/config/services.php +++ b/src/config/services.php @@ -1,79 +1,80 @@ [ 'domain' => env('MAILGUN_DOMAIN'), 'secret' => env('MAILGUN_SECRET'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), ], 'postmark' => [ 'token' => env('POSTMARK_TOKEN'), ], 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], 'sparkpost' => [ 'secret' => env('SPARKPOST_SECRET'), ], 'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'), 'mollie' => [ 'key' => env('MOLLIE_KEY'), ], 'stripe' => [ 'key' => env('STRIPE_KEY'), 'public_key' => env('STRIPE_PUBLIC_KEY'), 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], 'coinbase' => [ 'key' => env('COINBASE_KEY'), 'webhook_secret' => env('COINBASE_WEBHOOK_SECRET'), 'api_verify_tls' => env('COINBASE_VERIFY_TLS', true), ], 'openexchangerates' => [ 'api_key' => env('OPENEXCHANGERATES_API_KEY', null), ], 'dav' => [ 'uri' => env('DAV_URI', 'https://proxy/'), + 'default_folders' => \App\Backends\Helper::defaultDavFolders(), 'verify' => (bool) env('DAV_VERIFY', true), ], 'autodiscover' => [ 'uri' => env('AUTODISCOVER_URI', env('APP_URL', 'http://localhost')), ], 'activesync' => [ 'uri' => env('ACTIVESYNC_URI', 'https://proxy/Microsoft-Server-ActiveSync'), ], 'wopi' => [ 'uri' => env('WOPI_URI', 'http://roundcube/chwala/'), ], 'webmail' => [ 'uri' => env('WEBMAIL_URI', 'http://roundcube/roundcubemail/'), ] ]; diff --git a/src/phpunit.xml b/src/phpunit.xml index 9cb99bdd..cb01f1d9 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -1,47 +1,48 @@ tests/Unit tests/Functional tests/Feature tests/Browser tests/Browser/PaymentCoinbaseTest.php ./app + diff --git a/src/tests/Feature/Backends/DAVTest.php b/src/tests/Feature/Backends/DAVTest.php new file mode 100644 index 00000000..c0845194 --- /dev/null +++ b/src/tests/Feature/Backends/DAVTest.php @@ -0,0 +1,100 @@ +markTestSkipped(); + } + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + if ($this->user) { + $this->deleteTestUser($this->user->email); + } + + parent::tearDown(); + } + + /** + * Test initializing default folders for a user. + * + * @group imap + * @group dav + */ + public function testInitDefaultFolders(): void + { + Queue::fake(); + + $props = ['password' => 'test-pass']; + $this->user = $user = $this->getTestUser('davtest-' . time() . '@' . \config('app.domain'), $props); + + // Create the IMAP mailbox, it is required otherwise DAV requests will fail + \config(['imap.default_folders' => null]); + IMAP::createUser($user); + + $dav_folders = [ + [ + 'path' => 'Default', + 'displayname' => 'Calendar-Test', + 'components' => ['VEVENT'], + 'type' => 'calendar', + ], + [ + 'path' => 'Tasks', + 'displayname' => 'Tasks-Test', + 'components' => ['VTODO'], + 'type' => 'calendar', + ], + [ + 'path' => 'Default', + 'displayname' => 'Contacts-Test', + 'type' => 'addressbook', + ], + ]; + + \config(['services.dav.default_folders' => $dav_folders]); + DAV::initDefaultFolders($user); + + $dav = new DAV($user->email, $props['password']); + + $folders = $dav->listFolders(DAV::TYPE_VCARD); + $this->assertCount(1, $folders); + $this->assertSame('Contacts-Test', $folders[0]->name); + + $folders = $dav->listFolders(DAV::TYPE_VEVENT); + $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; }); + $folders = array_values($folders); + $this->assertCount(1, $folders); + $this->assertSame(['VEVENT'], $folders[0]->components); + $this->assertSame(['collection', 'calendar'], $folders[0]->types); + $this->assertSame('Calendar-Test', $folders[0]->name); + + $folders = $dav->listFolders(DAV::TYPE_VTODO); + $folders = array_filter($folders, function ($f) { return $f->name != 'Inbox' && $f->name != 'Outbox'; }); + $folders = array_values($folders); + $this->assertCount(1, $folders); + $this->assertSame(['VTODO'], $folders[0]->components); + $this->assertSame(['collection', 'calendar'], $folders[0]->types); + $this->assertSame('Tasks-Test', $folders[0]->name); + } +}