diff --git a/tests/Sync/MoveItemsTest.php b/tests/Sync/MoveItemsTest.php index 3eb40cb..d8d08d6 100644 --- a/tests/Sync/MoveItemsTest.php +++ b/tests/Sync/MoveItemsTest.php @@ -1,392 +1,401 @@ emptyTestFolder('INBOX', 'mail'); $this->emptyTestFolder('Trash', 'mail'); $uid = $this->appendMail('INBOX', 'mail.sync1'); $this->registerDevice(); $inbox = array_search('INBOX', $this->folders); $trash = array_search('Trash', $this->folders); // Initial sync $request = << 0 {$inbox} 0 {$trash} EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); // Sync mail from INBOX and Trash $request = << 1 {$inbox} 1 1 {$trash} 1 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")->count()); $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); $root = $xpath->query("ns:Commands/ns:Add", $root)->item(0); $this->assertSame('test sync', $xpath->query("ns:ApplicationData/Email:Subject", $root)->item(0)->nodeValue); // Move the message to $trash $request = << {$inbox}::{$uid} {$inbox} {$trash} EOF; $response = $this->request($request, 'MoveItems'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $xpath->registerNamespace('Move', 'uri:Move'); $root = $xpath->query("//Move:MoveItems/Move:Response")->item(0); $this->assertSame('3', $xpath->query("Move:Status", $root)->item(0)->nodeValue); $this->assertSame("{$inbox}::{$uid}", $xpath->query("Move:SrcMsgId", $root)->item(0)->nodeValue); $serverId = $xpath->query("Move:DstMsgId", $root)->item(0)->nodeValue; // Sync mail from INBOX and Trash $request = << 2 {$inbox} 1 1 {$trash} 1 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); // INBOX $this->assertSame($inbox, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); $this->assertSame(1, $xpath->query("ns:Commands/ns:Delete", $root)->count()); $this->assertSame("$inbox::$uid", $xpath->query("ns:Commands/ns:Delete/ns:ServerId", $root)->item(0)->nodeValue); $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(1); // Trash $this->assertSame($trash, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); $this->assertSame('test sync', $xpath->query("ns:Commands/ns:Add/ns:ApplicationData/Email:Subject", $root)->item(0)->nodeValue); } public function testInvalidMove() { $this->emptyTestFolder('INBOX', 'mail'); $this->emptyTestFolder('Trash', 'mail'); $uid = $this->appendMail('INBOX', 'mail.sync1'); $this->registerDevice(); $inbox = array_search('INBOX', $this->folders); $trash = array_search('Trash', $this->folders); // Move item that doesn't exist $request = << foobar::99999 foobar foobar EOF; $response = $this->request($request, 'MoveItems'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $xpath->registerNamespace('Move', 'uri:Move'); $root = $xpath->query("//Move:MoveItems/Move:Response")->item(0); $this->assertSame('1', $xpath->query("Move:Status", $root)->item(0)->nodeValue); // Move item that doesn't exist $request = << {$inbox}::99999 {$inbox} {$trash} EOF; $response = $this->request($request, 'MoveItems'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $xpath->registerNamespace('Move', 'uri:Move'); $root = $xpath->query("//Move:MoveItems/Move:Response")->item(0); $this->assertSame('1', $xpath->query("Move:Status", $root)->item(0)->nodeValue); } /** * Test moving a contact */ public function testMoveContact() { + if ($this->isStorageDriver('kolab')) { + // The Contacts folder is not available, and consequently appendObject fails + $this->markTestSkipped('This test only works with the DAV backend.'); + } + // Test with multi-folder support enabled self::$deviceType = 'iphone'; $davFolder = $this->isStorageDriver('kolab') ? 'Contacts' : 'Addressbook'; $this->emptyTestFolder($davFolder, 'contact'); $this->deleteTestFolder($folderName = 'Test Contacts Folder', 'contact'); $this->appendObject($davFolder, 'contact.vcard1', 'contact'); $this->registerDevice(); $srcFolderId = array_search($davFolder, $this->folders); + $this->assertTrue(!empty($srcFolderId)); // Create a contacts folder $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; $request = << 1 0 {$folderName} {$folderType} EOF; $response = $this->request($request, 'FolderCreate'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $dstFolderId = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue; // Sync both folders $request = << Contacts 0 {$srcFolderId} Contacts 0 {$dstFolderId} EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); $request = << Contacts 1 {$srcFolderId} 1 5120 1 Contacts 1 {$dstFolderId} 1 5120 1 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); $this->assertSame($srcFolderId, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); $this->assertSame('Jane', $xpath->query("ns:Commands/ns:Add/ns:ApplicationData/Contacts:FirstName", $root)->item(0)->nodeValue); $srcMsgId = $xpath->query("ns:Commands/ns:Add/ns:ServerId", $root)->item(0)->nodeValue; // Move the message to the other folder $request = << {$srcMsgId} {$srcFolderId} {$dstFolderId} EOF; $response = $this->request($request, 'MoveItems'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $xpath->registerNamespace('Move', 'uri:Move'); $root = $xpath->query("//Move:MoveItems/Move:Response")->item(0); $this->assertSame('3', $xpath->query("Move:Status", $root)->item(0)->nodeValue); $this->assertSame($srcMsgId, $xpath->query("Move:SrcMsgId", $root)->item(0)->nodeValue); $dstMsgId = $xpath->query("Move:DstMsgId", $root)->item(0)->nodeValue; // Sync the folders again $request = << Contacts 2 {$srcFolderId} 1 5120 1 Contacts 1 {$dstFolderId} 1 5120 1 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); // src folder $this->assertSame($srcFolderId, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); $this->assertSame(1, $xpath->query("ns:Commands/ns:Delete", $root)->count()); $this->assertSame($srcMsgId, $xpath->query("ns:Commands/ns:Delete/ns:ServerId", $root)->item(0)->nodeValue); $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(1); // dst folder $this->assertSame($dstFolderId, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); $this->assertSame('Jane', $xpath->query("ns:Commands/ns:Add/ns:ApplicationData/Contacts:FirstName", $root)->item(0)->nodeValue); $this->assertSame($dstMsgId, $xpath->query("ns:Commands/ns:Add/ns:ServerId", $root)->item(0)->nodeValue); $this->deleteTestFolder($folderName, 'contact'); } } diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php index 4ade25a..2698982 100644 --- a/tests/SyncTestCase.php +++ b/tests/SyncTestCase.php @@ -1,407 +1,409 @@ 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'); self::$host = $config->get('activesync_test_host', 'http://localhost:8000'); 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_content'); $db->query('DELETE FROM syncroton_relations_state'); self::$client = new \GuzzleHttp\Client([ 'http_errors' => false, 'base_uri' => self::$host, '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'); $db->query('DELETE FROM syncroton_relations_state'); } } /** * 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 {$filename} into {$folder}"); } return $uid; } /** * Run A SQL query */ protected function runSQLQuery($query) { $sync = \kolab_sync::get_instance(); $db = $sync->get_dbh(); $db->query($query); } /** * Mark an email message as read over IMAP */ protected function markMailAsRead($folder, $uids) { $imap = $this->getImapStorage(); return $imap->set_flag($uids, 'SEEN', $folder); } /** * List emails over IMAP */ protected function listEmails($folder, $uids) { $imap = $this->getImapStorage(); return $imap->list_flags($folder, $uids); } /** * 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"); + } else { + exit("Folder is missing"); } 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("AirSyncBase", "uri:AirSyncBase"); $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); } } }