diff --git a/docker/proxy/rootfs/etc/nginx/nginx.conf b/docker/proxy/rootfs/etc/nginx/nginx.conf --- a/docker/proxy/rootfs/etc/nginx/nginx.conf +++ b/docker/proxy/rootfs/etc/nginx/nginx.conf @@ -140,7 +140,9 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - rewrite ^/\\.well-known/(caldav|carddav) https://\$server_name/iRony/ redirect; + location ~ ^/\\.well-known/(caldav|carddav)(.*)$ { + return 301 /iRony/$2; + } location /iRony { auth_request /auth; diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php --- a/src/app/Backends/DAV.php +++ b/src/app/Backends/DAV.php @@ -26,7 +26,7 @@ */ public function __construct($user, $password) { - $this->url = \config('dav.uri'); + $this->url = \config('services.dav.uri'); $this->user = $user; $this->password = $password; } diff --git a/src/config/dav.php b/src/config/dav.php deleted file mode 100644 --- a/src/config/dav.php +++ /dev/null @@ -1,5 +0,0 @@ - env('DAV_URI', 'http://kolab/dav'), -]; diff --git a/src/config/services.php b/src/config/services.php --- a/src/config/services.php +++ b/src/config/services.php @@ -54,6 +54,21 @@ 'openexchangerates' => [ 'api_key' => env('OPENEXCHANGERATES_API_KEY', null), - ] + ], + 'dav' => [ + 'uri' => env('DAV_URI', 'https://proxy/'), + ], + + '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/tests/Infrastructure/ActivesyncTest.php b/src/tests/Infrastructure/ActivesyncTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Infrastructure/ActivesyncTest.php @@ -0,0 +1,184 @@ +loadXML($xml); + $encoder->encode($dom); + rewind($outputStream); + return stream_get_contents($outputStream); + } + + private static function fromWbxml($binary) + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $binary); + rewind($stream); + $decoder = new \Syncroton_Wbxml_Decoder($stream); + return $decoder->decode(); + } + + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + if (!self::$user) { + self::$user = $this->getTestUser('activesynctest@kolab.org', ['password' => 'simple123'], true); + } + if (!self::$deviceId) { + // By always creating a new device we force syncroton to initialize. + // Otherwise we work against uninitialized metadata (subscription states), + // because the account has been removed, but syncroton doesn't reinitalize the metadata for known devices. + self::$deviceId = (string) Str::uuid(); + } + if (!self::$client) { + self::$client = new \GuzzleHttp\Client([ + 'http_errors' => false, // No exceptions + 'base_uri' => \config("services.activesync.uri"), + 'verify' => false, + 'auth' => [self::$user->email, 'simple123'], + 'connect_timeout' => 10, + 'timeout' => 10, + 'headers' => [ + "Content-Type" => "application/xml; charset=utf-8", + "Depth" => "1", + ] + ]); + } + } + + public function testOptions() + { + $response = self::$client->request('OPTIONS', ''); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('14', $response->getHeader('MS-Server-ActiveSync')[0]); + $this->assertStringContainsString('14.1', $response->getHeader('MS-ASProtocolVersions')[0]); + $this->assertStringContainsString('FolderSync', $response->getHeader('MS-ASProtocolCommands')[0]); + } + + public function testList() + { + $user = self::$user; + $deviceId = self::$deviceId; + $request = << + + + 0 + + EOF; + $body = self::toWbxml($request); + $response = self::$client->request( + 'POST', + "?Cmd=FolderSync&User={$user->email}&DeviceId={$deviceId}&DeviceType=iphone", + [ + 'headers' => [ + "Content-Type" => "application/vnd.ms-sync.wbxml", + 'MS-ASProtocolVersion' => "14.0" + ], + 'body' => $body + ] + ); + $this->assertEquals(200, $response->getStatusCode()); + $dom = self::fromWbxml($response->getBody()); + $xml = $dom->saveXML(); + + $this->assertStringContainsString('INBOX', $xml); + // The hash is based on the name, so it's always the same + $inboxId = '38b950ebd62cd9a66929c89615d0fc04'; + $this->assertStringContainsString($inboxId, $xml); + //TODO for this to work we need to create the default folders in IMAP::createUser + // $this->assertStringContainsString('Drafts', $result); + // $this->assertStringContainsString('Sent', $result); + // $this->assertStringContainsString('Trash', $result); + // $this->assertStringContainsString('Calendar', $result); + // $this->assertStringContainsString('Contacts', $result); + + // Find the inbox for the next step + // $collectionIds = $dom->getElementsByTagName('ServerId'); + // $inboxId = $collectionIds[0]->nodeValue; + + return $inboxId; + } + + /** + * @depends testList + */ + public function testInitialSync($inboxId) + { + $user = self::$user; + $deviceId = self::$deviceId; + $request = << + + + + + 0 + {$inboxId} + 0 + 0 + 512 + + 0 + + 1 + 1 + + + + + 16 + + EOF; + $body = self::toWbxml($request); + $response = self::$client->request( + 'POST', + "?Cmd=Sync&User={$user->email}&DeviceId={$deviceId}&DeviceType=iphone", + [ + 'headers' => [ + "Content-Type" => "application/vnd.ms-sync.wbxml", + 'MS-ASProtocolVersion' => "14.0" + ], + 'body' => $body + ] + ); + $this->assertEquals(200, $response->getStatusCode()); + $dom = self::fromWbxml($response->getBody()); + $status = $dom->getElementsByTagName('Status'); + $this->assertEquals("1", $status[0]->nodeValue); + $collections = $dom->getElementsByTagName('Collection'); + $this->assertEquals(1, $collections->length); + $collection = $collections->item(0); + $this->assertEquals("Class", $collection->childNodes->item(0)->nodeName); + $this->assertEquals("Email", $collection->childNodes->item(0)->nodeValue); + $this->assertEquals("SyncKey", $collection->childNodes->item(1)->nodeName); + $this->assertEquals("1", $collection->childNodes->item(1)->nodeValue); + $this->assertEquals("Status", $collection->childNodes->item(3)->nodeName); + $this->assertEquals("1", $collection->childNodes->item(3)->nodeValue); + } + + /** + * @doesNotPerformAssertions + */ + public function testCleanup(): void + { + $this->deleteTestUser(self::$user->email); + } +} diff --git a/src/tests/Infrastructure/AutodiscoverTest.php b/src/tests/Infrastructure/AutodiscoverTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Infrastructure/AutodiscoverTest.php @@ -0,0 +1,92 @@ + false, // No exceptions + 'base_uri' => "http://roundcube/", + 'verify' => false, + 'connect_timeout' => 10, + 'timeout' => 10, + ]); + } + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + parent::tearDown(); + } + + public function testWellKnownOutlook() + { + $body = << + + admin@example.local + + http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a + + + + EOF; + $response = self::$client->request('POST', 'autodiscover/autodiscover.xml', [ + 'headers' => [ + "Content-Type" => "text/xml; charset=utf-8" + ], + 'body' => $body + ]); + $this->assertEquals($response->getStatusCode(), 200); + $data = $response->getBody(); + $this->assertTrue(str_contains($data, 'example.local')); + $this->assertTrue(str_contains($data, 'admin@example.local')); + } + + public function testWellKnownActivesync() + { + $body = << + + admin@example.local + + http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006 + + + + EOF; + $response = self::$client->request('POST', 'autodiscover/autodiscover.xml', [ + 'headers' => [ + "Content-Type" => "text/xml; charset=utf-8" + ], + 'body' => $body + ]); + $this->assertEquals($response->getStatusCode(), 200); + $data = $response->getBody(); + $this->assertTrue(str_contains($data, 'https://example.local/Microsoft-Server-ActiveSync')); + $this->assertTrue(str_contains($data, 'admin@example.local')); + } + + public function testWellKnownMail() + { + $response = self::$client->request( + 'GET', + '.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=fred@example.com' + ); + $this->assertEquals($response->getStatusCode(), 200); + } +} diff --git a/src/tests/Infrastructure/DavTest.php b/src/tests/Infrastructure/DavTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Infrastructure/DavTest.php @@ -0,0 +1,293 @@ +getTestUser('davtest@kolab.org', ['password' => 'simple123'], true); + } + + if (!self::$client) { + self::$client = new \GuzzleHttp\Client([ + 'http_errors' => false, // No exceptions + 'base_uri' => \config("services.dav.uri"), + 'verify' => false, + 'auth' => [self::$user->email, 'simple123'], + 'connect_timeout' => 10, + 'timeout' => 10, + 'headers' => [ + "Content-Type" => "application/xml; charset=utf-8", + "Depth" => "1", + ] + ]); + } + } + + public function testDiscoverPrincipal() + { + $user = self::$user; + $body = ""; + $response = self::$client->request('PROPFIND', '/iRony/', ['body' => $body]); + $this->assertEquals(207, $response->getStatusCode()); + $data = $response->getBody(); + $this->assertStringContainsString("/iRony/principals/{$user->email}/", $data); + $this->assertStringContainsString('/iRony/calendars/', $data); + $this->assertStringContainsString('/iRony/addressbooks/', $data); + } + + /** + * This codepath is triggerd by MacOS CalDAV when it tries to login. + * Verify we don't crash and end up with a 500 status code. + */ + public function testFailingLogin() + { + $body = ""; + $headers = [ + "Content-Type" => "application/xml; charset=utf-8", + "Depth" => "1", + 'body' => $body, + 'auth' => ['invaliduser@kolab.org', 'invalid'] + ]; + + $response = self::$client->request('PROPFIND', '/iRony/', $headers); + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * This codepath is triggerd by MacOS CardDAV when it tries to login. + * NOTE: This depends on the username_domain roundcube config option. + */ + public function testShortlogin() + { + $this->markTestSkipped( + 'Shortlogins dont work with the nginx proxy.' + ); + $body = ""; + $response = self::$client->request('PROPFIND', '/iRony/', [ + 'body' => $body, + 'auth' => ['davtest', 'simple123'] + ]); + $this->assertEquals(207, $response->getStatusCode()); + } + + public function testDiscoverCalendarHomeset() + { + $user = self::$user; + $body = << + + + + + EOF; + + $response = self::$client->request('PROPFIND', '/iRony/', ['body' => $body]); + $this->assertEquals(207, $response->getStatusCode()); + $data = $response->getBody(); + $this->assertStringContainsString("/iRony/calendars/{$user->email}/", $data); + } + + public function testDiscoverCalendars() + { + $user = self::$user; + $body = << + + + + + + + + EOF; + + $response = self::$client->request('PROPFIND', "/iRony/calendars/{$user->email}", [ + 'headers' => [ + "Depth" => "infinity", + ], + 'body' => $body + ]); + $this->assertEquals(207, $response->getStatusCode()); + $data = $response->getBody(); + $this->assertStringContainsString("/iRony/calendars/{$user->email}/", $data); + + $doc = new \DOMDocument('1.0', 'UTF-8'); + $doc->loadXML($data); + $response = $doc->getElementsByTagName('response')->item(1); + $doc->getElementsByTagName('href')->item(0); + + $this->assertEquals("d:href", $response->childNodes->item(0)->nodeName); + $href = $response->childNodes->item(0)->nodeValue; + return $href; + } + + /** + * @depends testDiscoverCalendars + */ + public function testPropfindCalendar($href) + { + $body = << + + + + + + + + + + + EOF; + + $response = self::$client->request('PROPFIND', $href, [ + 'headers' => [ + "Depth" => "0", + ], + 'body' => $body, + ]); + $this->assertEquals(207, $response->getStatusCode()); + $data = $response->getBody(); + $this->assertStringContainsString("$href", $data); + } + + /** + * Thunderbird does this and relies on the WWW-Authenticate header response to + * start sending authenticated requests. + * + * @depends testDiscoverCalendars + */ + public function testPropfindCalendarWithoutAuth($href) + { + $body = << + + + + + + + + + + + EOF; + + $response = self::$client->request('PROPFIND', $href, [ + 'headers' => [ + "Depth" => "0", + ], + 'body' => $body, + 'auth' => [] + ]); + $this->assertEquals(401, $response->getStatusCode()); + $this->assertStringContainsString('Basic realm=', $response->getHeader('WWW-Authenticate')[0]); + $data = $response->getBody(); + $this->assertStringContainsString("Sabre\DAV\Exception\NotAuthenticated", $data); + } + + /** + * Required for MacOS autoconfig + */ + public function testOptions() + { + $user = self::$user; + $body = << + + + + + + + + EOF; + + $response = self::$client->request('OPTIONS', "/iRony/principals/{$user->email}/", ['body' => $body]); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('PROPFIND', $response->getHeader('Allow')[0]); + } + + public function testWellKnown() + { + $user = self::$user; + $body = << + + + + + + + + EOF; + + // The base URL needs to work as a redirect + $response = self::$client->request('PROPFIND', '/.well-known/caldav', [ + 'headers' => [ + "Depth" => "infinity", + ], + 'body' => $body, + 'allow_redirects' => false + ]); + $this->assertEquals(301, $response->getStatusCode()); + $redirectTarget = $response->getHeader('location')[0]; + $this->assertEquals(\config('services.dav.uri') . "iRony/", $redirectTarget); + + // Follow the redirect + $response = self::$client->request('PROPFIND', $redirectTarget, [ + 'headers' => [ + "Depth" => "infinity", + ], + 'body' => $body, + 'allow_redirects' => false + ]); + $this->assertEquals(207, $response->getStatusCode()); + + // Any URL should result in a redirect to the same path + $response = self::$client->request('PROPFIND', "/.well-known/caldav/calendars/{$user->email}", [ + 'headers' => [ + "Depth" => "infinity", + ], + 'body' => $body, + 'allow_redirects' => false + ]); + $this->assertEquals(301, $response->getStatusCode()); + $redirectTarget = $response->getHeader('location')[0]; + //FIXME we have an extra slash that we don't technically want here + $this->assertEquals(\config('services.dav.uri') . "iRony//calendars/{$user->email}", $redirectTarget); + + // Follow the redirect + $response = self::$client->request('PROPFIND', $redirectTarget, [ + 'headers' => [ + "Depth" => "infinity", + ], + 'body' => $body, + 'allow_redirects' => false + ]); + $this->assertEquals(207, $response->getStatusCode()); + $data = $response->getBody(); + $this->assertStringContainsString("/iRony/calendars/{$user->email}/", $data); + } + + /** + * @doesNotPerformAssertions + */ + public function testCleanup(): void + { + $this->deleteTestUser(self::$user->email); + } +} diff --git a/src/tests/Infrastructure/RoundcubeTest.php b/src/tests/Infrastructure/RoundcubeTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Infrastructure/RoundcubeTest.php @@ -0,0 +1,81 @@ +getTestUser('roundcubetesttest@kolab.org', ['password' => 'simple123'], true); + } + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + parent::tearDown(); + } + + public function testLogin() + { + $this->browse(function (Browser $browser) { + $browser->visit('/') + ->type('#rcmloginuser', self::$user->email) + ->type('#rcmloginpwd', "simple123") + ->press('#rcmloginsubmit') + ->waitFor('#logo') + ->waitUntil('!rcmail.busy') + ->assertSee('Inbox'); + + $browser->press('.contacts') + ->waitUntil('!rcmail.busy') + ->assertVisible('#directorylist') + ->assertVisible('.addressbook.personal') + ->assertSee('Contacts'); + + $browser->press('.button-calendar') + ->waitUntil('!rcmail.busy') + ->assertSee('Calendar'); + + //TODO requires the default folders to be created + // $browser->press('.button-files') + // ->waitUntil('!rcmail.busy') + // ->assertSeeIn('#files-folder-list', 'Files'); + + $browser->press('.button-notes') + ->waitUntil('!rcmail.busy') + ->assertSeeIn('#notebooks-content', 'Notes'); + + $browser->press('.button-tasklist') + ->waitUntil('!rcmail.busy') + ->assertSee('Tasks'); + + $browser->press('.settings') + ->waitUntil('!rcmail.busy') + ->assertSee('Activesync'); + // print($browser->dump()); + }); + } + + /** + * @doesNotPerformAssertions + */ + public function testCleanup(): void + { + $this->deleteTestUser(self::$user->email); + } +} diff --git a/src/tests/Infrastructure/WOPITest.php b/src/tests/Infrastructure/WOPITest.php new file mode 100644 --- /dev/null +++ b/src/tests/Infrastructure/WOPITest.php @@ -0,0 +1,65 @@ +getTestUser('wopitest@kolab.org', ['password' => 'simple123'], true); + } + + if (!self::$client) { + self::$client = new \GuzzleHttp\Client([ + 'base_uri' => \config('services.wopi.uri'), + 'verify' => false, + 'auth' => [self::$user->email, 'simple123'], + 'connect_timeout' => 10, + 'timeout' => 10 + ]); + } + } + + public function testAccess() + { + $response = self::$client->request('GET', 'api/?method=authenticate&version=4'); + $this->assertEquals($response->getStatusCode(), 200); + $json = json_decode($response->getBody(), true); + + $this->assertEquals('OK', $json['status']); + $token = $json['result']['token']; + $this->assertTrue(!empty($token)); + + //FIXME the session token doesn't seem to be required here? + $response = self::$client->request('GET', 'api/?method=mimetypes', [ + 'headers' => [ + 'X-Session_token' => $token + ] + ]); + $this->assertEquals($response->getStatusCode(), 200); + $json = json_decode($response->getBody(), true); + $this->assertEquals('OK', $json['status']); + $this->assertEquals('OK', $json['status']); + $this->assertContains('image/png', $json['result']['view']); + $this->assertArrayHasKey('text/plain', $json['result']['edit']); + } + + /** + * @doesNotPerformAssertions + */ + public function testCleanup(): void + { + $this->deleteTestUser(self::$user->email); + } +} diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -2,6 +2,7 @@ namespace Tests; +use App\Backends\IMAP; use App\Backends\LDAP; use App\CompanionApp; use App\Domain; @@ -403,7 +404,12 @@ return; } - LDAP::deleteUser($user); + if (\config('app.with_imap')) { + IMAP::deleteUser($user); + } + if (\config('app.with_ldap')) { + LDAP::deleteUser($user); + } $user->forceDelete(); } @@ -553,10 +559,12 @@ * * @coversNothing */ - protected function getTestUser($email, $attrib = []) + protected function getTestUser($email, $attrib = [], $createInBackends = false) { // Disable jobs (i.e. skip LDAP oprations) - Queue::fake(); + if (!$createInBackends) { + Queue::fake(); + } $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) {