diff --git a/tests/Sync/Sync/EmailTest.php b/tests/Sync/Sync/EmailTest.php index 77f306e..96e34a4 100644 --- a/tests/Sync/Sync/EmailTest.php +++ b/tests/Sync/Sync/EmailTest.php @@ -1,179 +1,270 @@ emptyTestFolder('INBOX', 'mail'); $this->registerDevice(); // Test invalid collection identifier $request = << 0 1111111111 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $this->assertSame('12', $xpath->query("//ns:Sync/ns:Status")->item(0)->nodeValue); // Test INBOX $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $syncKey = 0; $request = << {$syncKey} {$folderId} EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $this->assertSame('1', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:SyncKey")->item(0)->nodeValue); $this->assertSame('Email', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Class")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:CollectionId")->item(0)->nodeValue); // Test listing mail in INBOX, use WindowSize=1 // Append two mail messages $this->appendMail('INBOX', 'mail.sync1'); $this->appendMail('INBOX', 'mail.sync2'); $request = << {$syncKey} {$folderId} 1 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); // Note: We assume messages are in IMAP default order, it may change in future $root .= "/ns:Commands/ns:Add"; $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); $this->assertSame('test sync', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue); // List the rest of the mail $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); // Note: We assume messages are in IMAP default order, it may change in future $root .= "/ns:Commands/ns:Add"; $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); $this->assertSame('sync test with attachment', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue); return $syncKey; } + /** + * Test empty sync response + * + * @depends testSync + */ + public function testEmptySync($syncKey) + { + $folderId = '38b950ebd62cd9a66929c89615d0fc04'; + $request = << + + + + + {$syncKey} + {$folderId} + 1 + 1 + + 0 + 1 + + 2 + 51200 + 0 + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + // We expect an empty response without a change + $this->assertEquals(0, $response->getBody()->getSize()); + + return $syncKey; + } + + /** + * Test flag change + * + * @depends testEmptySync + */ + public function testFlagChange($syncKey) + { + $this->assertTrue($this->markMailAsRead('INBOX', '*')); + + $folderId = '38b950ebd62cd9a66929c89615d0fc04'; + $request = << + + + + + {$syncKey} + {$folderId} + 1 + 1 + + 0 + 1 + + 2 + 51200 + 0 + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + print($dom->saveXML()); + + $root = "//ns:Sync/ns:Collections/ns:Collection"; + $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); + return $syncKey; + } + /** * Test updating message properties from client * * @depends testSync */ public function testChangeFromClient($syncKey) { $this->markTestIncomplete(); } /** * Test deleting messages from client * * @depends testChangeFromClient */ public function testDeleteFromClient($syncKey) { $this->markTestIncomplete(); } } diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php index bfe6395..d5fd9ab 100644 --- a/tests/SyncTestCase.php +++ b/tests/SyncTestCase.php @@ -1,375 +1,385 @@ markTestSkipped('Not setup'); } self::$deviceType = null; } /** * {@inheritDoc} */ public static function setUpBeforeClass(): void { $sync = \kolab_sync::get_instance(); $config = $sync->config; $db = $sync->get_dbh(); self::$username = $config->get('activesync_test_username'); self::$password = $config->get('activesync_test_password'); if (empty(self::$username)) { return; } self::$deviceId = 'test' . time(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); $db->query('DELETE FROM syncroton_data'); $db->query('DELETE FROM syncroton_data_folder'); $db->query('DELETE FROM syncroton_modseq'); $db->query('DELETE FROM syncroton_content'); self::$client = new \GuzzleHttp\Client([ 'http_errors' => false, 'base_uri' => 'http://localhost:8000', 'verify' => false, 'auth' => [self::$username, self::$password], 'connect_timeout' => 10, 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/xml; charset=utf-8', 'Depth' => '1', ], ]); // TODO: execute: php -S localhost:8000 } /** * {@inheritDoc} */ public static function tearDownAfterClass(): void { if (self::$deviceId) { $sync = \kolab_sync::get_instance(); if (self::$authenticated || $sync->authenticate(self::$username, self::$password)) { $sync->password = self::$password; $storage = $sync->storage(); $storage->device_delete(self::$deviceId); } $db = $sync->get_dbh(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); } } /** * Append an email message to the IMAP folder */ protected function appendMail($folder, $filename, $replace = []) { $imap = $this->getImapStorage(); $source = __DIR__ . '/src/' . $filename; if (!file_exists($source)) { exit("File does not exist: {$source}"); } $is_file = true; if (!empty($replace)) { $is_file = false; $source = file_get_contents($source); foreach ($replace as $token => $value) { $source = str_replace($token, $value, $source); } } $uid = $imap->save_message($folder, $source, '', $is_file); if ($uid === false) { exit("Failed to append mail into {$folder}"); } return $uid; } + /** + * Mark an email message as read over IMAP + */ + protected function markMailAsRead($folder, $uids) + { + $imap = $this->getImapStorage(); + + return $imap->set_flag($uids, 'SEEN', $folder); + } + /** * Append an DAV object to a DAV/IMAP folder */ protected function appendObject($foldername, $filename, $type) { $path = __DIR__ . '/src/' . $filename; if (!file_exists($path)) { exit("File does not exist: {$path}"); } $content = file_get_contents($path); $uid = preg_match('/UID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null; if (empty($uid)) { exit("Filed to find UID in {$path}"); } if ($this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($foldername)) { // TODO exit("Not implemented for Kolab v3 storage driver"); } return; } $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $foldername) { $dav_type = $folder->get_dav_type(); $location = $folder->object_location($uid); if ($folder->dav->create($location, $content, $dav_type) !== false) { return; } } } exit("Failed to append object into {$foldername}"); } /** * Delete a folder */ protected function deleteTestFolder($name, $type) { // Deleting IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($name)) { $imap->delete_folder($name); } return; } // Deleting DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $dav->folder_delete($folder->id, $type); } } } /** * Remove all objects from a folder */ protected function emptyTestFolder($name, $type) { // Deleting in IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); $imap->delete_message('*', $name); return; } // Deleting in DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $folder->delete_all(); } } } /** * Convert WBXML binary content into XML */ protected function fromWbxml($binary) { $stream = fopen('php://memory', 'r+'); fwrite($stream, $binary); rewind($stream); $decoder = new \Syncroton_Wbxml_Decoder($stream); return $decoder->decode(); } /** * Initialize DAV storage */ protected function getDavStorage() { $sync = \kolab_sync::get_instance(); $url = $sync->config->get('activesync_dav_server', 'http://localhost'); if (strpos($url, '://') === false) { $url = 'http://' . $url; } // Inject user+password to the URL, there's no other way to pass it to the DAV client $url = str_replace('://', '://' . rawurlencode(self::$username) . ':' . rawurlencode(self::$password) . '@', $url); // Make sure user is authenticated $this->getImapStorage(); if (!empty($sync->user)) { // required e.g. for DAV client cache use \rcube::get_instance()->user = $sync->user; } return new \kolab_storage_dav($url); } /** * Initialize IMAP storage */ protected function getImapStorage() { $sync = \kolab_sync::get_instance(); if (!self::$authenticated) { if ($sync->authenticate(self::$username, self::$password)) { self::$authenticated = true; $sync->password = self::$password; } } return $sync->get_storage(); } /** * Check the configured activesync_storage driver */ protected function isStorageDriver($name) { return $name === \kolab_sync::get_instance()->config->get('activesync_storage', 'kolab'); } /** * Make a HTTP request to the ActiveSync server */ protected function request($body, $cmd, $type = 'POST') { $username = self::$username; $deviceId = self::$deviceId; $deviceType = self::$deviceType ?: 'WindowsOutlook15'; $body = $this->toWbxml($body); return self::$client->request( $type, "?Cmd={$cmd}&User={$username}&DeviceId={$deviceId}&DeviceType={$deviceType}", [ 'headers' => [ 'Content-Type' => 'application/vnd.ms-sync.wbxml', 'MS-ASProtocolVersion' => '14.0', ], 'body' => $body, ] ); } /** * Register the device for tests, some commands do not work until device/folders are registered */ protected function registerDevice() { // Execute initial FolderSync, it is required before executing some commands $request = << 0 EOF; $response = $this->request($request, 'FolderSync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); foreach ($xpath->query("//ns:FolderSync/ns:Changes/ns:Add") as $idx => $folder) { $serverId = $folder->getElementsByTagName('ServerId')->item(0)->nodeValue; $displayName = $folder->getElementsByTagName('DisplayName')->item(0)->nodeValue; $this->folders[$serverId] = $displayName; } } /** * Convert XML into WBXML binary content */ protected function toWbxml($xml) { $outputStream = fopen('php://temp', 'r+'); $encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3); $dom = new \DOMDocument(); $dom->loadXML($xml); $encoder->encode($dom); rewind($outputStream); return stream_get_contents($outputStream); } /** * Get XPath from a DOM */ protected function xpath($dom) { $xpath = new \DOMXpath($dom); $xpath->registerNamespace("ns", $dom->documentElement->namespaceURI); $xpath->registerNamespace("AirSync", "uri:AirSync"); $xpath->registerNamespace("Calendar", "uri:Calendar"); $xpath->registerNamespace("Contacts", "uri:Contacts"); $xpath->registerNamespace("Email", "uri:Email"); $xpath->registerNamespace("Email2", "uri:Email2"); $xpath->registerNamespace("Settings", "uri:Settings"); $xpath->registerNamespace("Tasks", "uri:Tasks"); return $xpath; } /** * adapter for phpunit < 9 */ public static function assertMatchesRegularExpression(string $arg1, string $arg2, string $message = ''): void { if (method_exists("PHPUnit\Framework\TestCase", "assertMatchesRegularExpression")) { parent::assertMatchesRegularExpression($arg1, $arg2, $message); } else { parent::assertRegExp($arg1, $arg2); } } }