diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index 6186e14d..7c986446 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,269 +1,267 @@ output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage("{$message}..."); } $bar->start(); return $bar; } /** * Find the domain. * * @param string $domain Domain ID or namespace * @param bool $withDeleted Include deleted * * @return \App\Domain|null */ public function getDomain($domain, $withDeleted = false) { return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find a group. * * @param string $group Group ID or email * @param bool $withDeleted Include deleted * * @return \App\Group|null */ public function getGroup($group, $withDeleted = false) { return $this->getObject(\App\Group::class, $group, 'email', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { if (!$withDeleted) { - // @phpstan-ignore-next-line $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); if (!$object && !empty($objectTitle)) { $object = $this->getObjectModel($objectClass, $withDeleted) ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } /** * Returns a preconfigured Model object for a specified class. * * @param string $objectClass The name of the class * @param bool $withDeleted Include withTrashed() query * * @return mixed */ protected function getObjectModel($objectClass, $withDeleted = false) { if ($withDeleted) { $model = $objectClass::withTrashed(); } else { $model = new $objectClass(); } if ($this->commandPrefix == 'scalpel') { return $model; } $modelsWithOwner = [ \App\Wallet::class, ]; $tenantId = \config('app.tenant_id'); // Add tenant filter if (in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($objectClass))) { $model = $model->withEnvTenantContext(); } elseif (in_array($objectClass, $modelsWithOwner)) { $model = $model->whereExists(function ($query) use ($tenantId) { $query->select(DB::raw(1)) ->from('users') ->whereRaw('wallets.user_id = users.id') ->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null')); }); } return $model; } /** * Find a resource. * * @param string $resource Resource ID or email * @param bool $withDeleted Include deleted * * @return \App\Resource|null */ public function getResource($resource, $withDeleted = false) { return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); } /** * Find a shared folder. * * @param string $folder Folder ID or email * @param bool $withDeleted Include deleted * * @return \App\SharedFolder|null */ public function getSharedFolder($folder, $withDeleted = false) { return $this->getObject(\App\SharedFolder::class, $folder, 'email', $withDeleted); } /** * Find the user. * * @param string $user User ID or email * @param bool $withDeleted Include deleted * * @return \App\User|null */ public function getUser($user, $withDeleted = false) { return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet, null); } /** * Execute the console command. * * @return mixed */ public function handle() { if ($this->dangerous) { $this->warn( "This command is a dangerous scalpel command with potentially significant unintended consequences" ); $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); if (!$confirmation) { $this->info("Better safe than sorry."); return false; } $this->info("VĂ¡monos!"); } return true; } /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; - // @phpstan-ignore-next-line foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/ObjectListCommand.php b/src/app/Console/ObjectListCommand.php index 51008c8f..67f64d5f 100644 --- a/src/app/Console/ObjectListCommand.php +++ b/src/app/Console/ObjectListCommand.php @@ -1,114 +1,113 @@ description = "List all {$this->objectName} objects"; $this->signature = $this->commandPrefix ? $this->commandPrefix . ":" : ""; if (!empty($this->objectNamePlural)) { $this->signature .= "{$this->objectNamePlural}"; } else { $this->signature .= "{$this->objectName}s"; } $classes = class_uses_recursive($this->objectClass); if (in_array(SoftDeletes::class, $classes)) { $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; } $this->signature .= " {--attr=* : Attributes other than the primary unique key to include}" . "{--filter=* : Additional filter(s) or a raw SQL WHERE clause}"; parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { $classes = class_uses_recursive($this->objectClass); - // @phpstan-ignore-next-line if (in_array(SoftDeletes::class, $classes) && $this->option('with-deleted')) { $objects = $this->objectClass::withTrashed(); } else { $objects = new $this->objectClass(); } foreach ($this->option('filter') as $filter) { $objects = $this->applyFilter($objects, $filter); } foreach ($objects->cursor() as $object) { if ($object->deleted_at) { $this->info("{$this->toString($object)} (deleted at {$object->deleted_at}"); } else { $this->info("{$this->toString($object)}"); } } } /** * Apply pre-configured filter or raw WHERE clause to the main query. * * @param object $query Query builder * @param string $filter Pre-defined filter identifier or raw SQL WHERE clause * * @return object Query builder */ public function applyFilter($query, string $filter) { // Get objects marked as deleted, i.e. --filter=TRASHED // Note: For use with --with-deleted option if (strtolower($filter) === 'trashed') { return $query->whereNotNull('deleted_at'); } // Get objects with specified status, e.g. --filter=STATUS:SUSPENDED if (preg_match('/^status:([a-z]+)$/i', $filter, $matches)) { $status = strtoupper($matches[1]); $const = "{$this->objectClass}::STATUS_{$status}"; if (defined($const)) { return $query->where('status', '&', constant($const)); } throw new \Exception("Unknown status in --filter={$filter}"); } // Get objects older/younger than specified time, e.g. --filter=MIN-AGE:1Y if (preg_match('/^(min|max)-age:([0-9]+)([mdy])$/i', $filter, $matches)) { $operator = strtolower($matches[1]) == 'min' ? '<=' : '>='; $count = $matches[2]; $period = strtolower($matches[3]); $date = \Carbon\Carbon::now(); if ($period == 'y') { $date->subYearsWithoutOverflow($count); } elseif ($period == 'm') { $date->subMonthsWithoutOverflow($count); } else { $date->subDays($count); } return $query->where('created_at', $operator, $date); } return $query->whereRaw($filter); } } diff --git a/src/tests/Infrastructure/ActivesyncTest.php b/src/tests/Infrastructure/ActivesyncTest.php index 235edf39..dfad7002 100644 --- a/src/tests/Infrastructure/ActivesyncTest.php +++ b/src/tests/Infrastructure/ActivesyncTest.php @@ -1,641 +1,647 @@ 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(); } private function request($request, $cmd) { $user = self::$user; $deviceId = self::$deviceId; $body = self::toWbxml($request); return self::$client->request( 'POST', "?Cmd={$cmd}&User={$user->email}&DeviceId={$deviceId}&DeviceType=iphone", [ 'headers' => [ "Content-Type" => "application/vnd.ms-sync.wbxml", 'MS-ASProtocolVersion' => "14.0" ], 'body' => $body ] ); } private function xpath($dom) { $xpath = new \DOMXpath($dom); $xpath->registerNamespace("ns", $dom->documentElement->namespaceURI); $xpath->registerNamespace("Tasks", "uri:Tasks"); return $xpath; } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); 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(); } $deviceId = self::$deviceId; \config(['imap.default_folders' => [ 'Drafts' => [ 'metadata' => [ '/private/vendor/kolab/folder-type' => 'mail.drafts', '/private/vendor/kolab/activesync' => "{\"FOLDER\":{\"{$deviceId}\":{\"S\":1}}}" ], ], 'Calendar' => [ 'metadata' => [ '/private/vendor/kolab/folder-type' => 'event.default', '/private/vendor/kolab/activesync' => "{\"FOLDER\":{\"{$deviceId}\":{\"S\":1}}}" ], ], 'Tasks' => [ 'metadata' => [ '/private/vendor/kolab/folder-type' => 'task.default', '/private/vendor/kolab/activesync' => "{\"FOLDER\":{\"{$deviceId}\":{\"S\":1}}}" ], ], 'Contacts' => [ 'metadata' => [ '/private/vendor/kolab/folder-type' => 'contact.default', '/private/vendor/kolab/activesync' => "{\"FOLDER\":{\"{$deviceId}\":{\"S\":1}}}" ], ], ]]); if (!self::$user) { self::$user = $this->getTestUser('activesynctest@kolab.org', ['password' => 'simple123'], true); //FIXME this shouldn't be required, but it seems to be. Roundcube::dbh()->table('kolab_cache_task')->truncate(); } 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() { $request = << 0 EOF; $response = $this->request($request, 'FolderSync'); $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); $this->assertStringContainsString('Drafts', $xml); $this->assertStringContainsString('Calendar', $xml); $this->assertStringContainsString('Tasks', $xml); $this->assertStringContainsString('Contacts', $xml); // Find the inbox for the next step // $collectionIds = $dom->getElementsByTagName('ServerId'); // $inboxId = $collectionIds[0]->nodeValue; return $inboxId; } /** * @depends testList */ public function testInitialSync($inboxId) { $request = << 0 {$inboxId} 0 0 512 0 1 1 16 EOF; $response = $this->request($request, 'Sync'); $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); return $inboxId; } /** * @depends testInitialSync */ public function testAdd($inboxId) { $request = << 1 {$inboxId} 0 0 512 0 1 1 16 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()); } /** * @depends testList */ public function testSyncTasks() { $tasksId = "90335880f65deff6e521acea2b71a773"; $request = << 0 {$tasksId} 0 0 512 0 1 1 16 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $request = << 1 {$tasksId} 0 0 512 0 1 1 16 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); return $tasksId; } /** * @depends testSyncTasks */ public function testAddTask($tasksId) { $request = << 1 {$tasksId} clientId1 task1 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z 16 EOF; $response = $this->request($request, 'Sync'); $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("Tasks", $collection->childNodes->item(0)->nodeValue); $this->assertEquals("SyncKey", $collection->childNodes->item(1)->nodeName); $this->assertEquals("2", $collection->childNodes->item(1)->nodeValue); $this->assertEquals("Status", $collection->childNodes->item(3)->nodeName); $this->assertEquals("1", $collection->childNodes->item(3)->nodeValue); $xpath = $this->xpath($dom); $add = $xpath->query("//ns:Responses/ns:Add"); $this->assertEquals(1, $add->length); $this->assertEquals("clientId1", $xpath->query("//ns:Responses/ns:Add/ns:ClientId")->item(0)->nodeValue); $this->assertEquals(0, $xpath->query("//ns:Commands")->length); return [ 'collectionId' => $tasksId, 'serverId1' => $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(0)->nodeValue ]; } /** * Re-issuing the same command should not result in the sync key being invalidated. * * @depends testAddTask */ public function testReAddTask($result) { $tasksId = $result['collectionId']; $request = << 1 {$tasksId} clientId1 task1 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z 16 EOF; $response = $this->request($request, 'Sync'); $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("Tasks", $collection->childNodes->item(0)->nodeValue); $this->assertEquals("SyncKey", $collection->childNodes->item(1)->nodeName); $this->assertEquals("2", $collection->childNodes->item(1)->nodeValue); $this->assertEquals("Status", $collection->childNodes->item(3)->nodeName); $this->assertEquals("1", $collection->childNodes->item(3)->nodeValue); $xpath = $this->xpath($dom); $add = $xpath->query("//ns:Responses/ns:Add"); $this->assertEquals(1, $add->length); $this->assertEquals("clientId1", $xpath->query("//ns:Responses/ns:Add/ns:ClientId")->item(0)->nodeValue); $this->assertEquals(0, $xpath->query("//ns:Commands")->length); return [ 'collectionId' => $tasksId, 'serverId1' => $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(0)->nodeValue ]; } /** * Make sure we can continue with the sync after the previous hickup, also include a modification. * * @depends testAddTask */ public function testAddTaskContinued($result) { $tasksId = $result['collectionId']; $serverId = $result['serverId1']; $request = << 2 {$tasksId} clientId2 task2 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z clientId3 task3 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z {$serverId} task4 16 EOF; $response = $this->request($request, 'Sync'); $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("Tasks", $collection->childNodes->item(0)->nodeValue); $this->assertEquals("SyncKey", $collection->childNodes->item(1)->nodeName); $this->assertEquals("3", $collection->childNodes->item(1)->nodeValue); $this->assertEquals("Status", $collection->childNodes->item(3)->nodeName); $this->assertEquals("1", $collection->childNodes->item(3)->nodeValue); $xpath = $this->xpath($dom); $add = $xpath->query("//ns:Responses/ns:Add"); $this->assertEquals(2, $add->length); $this->assertEquals("clientId2", $xpath->query("//ns:Responses/ns:Add/ns:ClientId")->item(0)->nodeValue); $this->assertEquals("clientId3", $xpath->query("//ns:Responses/ns:Add/ns:ClientId")->item(1)->nodeValue); $this->assertEquals(0, $xpath->query("//ns:Commands")->length); // The server does not have to inform about a successful change $change = $xpath->query("//ns:Responses/ns:Change"); $this->assertEquals(0, $change->length); return [ 'collectionId' => $tasksId, 'serverId1' => $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(0)->nodeValue, 'serverId2' => $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(1)->nodeValue ]; } /** * Perform another duplicate request. * * @depends testAddTaskContinued */ public function testAddTaskContinuedAgain($result) { $tasksId = $result['collectionId']; $serverId = $result['serverId1']; $request = << 2 {$tasksId} clientId2 task2 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z clientId3 task3 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z {$serverId} task4 16 EOF; $response = $this->request($request, 'Sync'); $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("Tasks", $collection->childNodes->item(0)->nodeValue); $this->assertEquals("SyncKey", $collection->childNodes->item(1)->nodeName); $this->assertEquals("3", $collection->childNodes->item(1)->nodeValue); $this->assertEquals("Status", $collection->childNodes->item(3)->nodeName); $this->assertEquals("1", $collection->childNodes->item(3)->nodeValue); $xpath = $this->xpath($dom); print($dom->saveXML()); $add = $xpath->query("//ns:Responses/ns:Add"); $this->assertEquals(2, $add->length); $this->assertEquals("clientId2", $xpath->query("//ns:Responses/ns:Add/ns:ClientId")->item(0)->nodeValue); - $this->assertEquals($result['serverId1'], $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(0)->nodeValue); + $this->assertEquals( + $result['serverId1'], + $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(0)->nodeValue + ); $this->assertEquals("clientId3", $xpath->query("//ns:Responses/ns:Add/ns:ClientId")->item(1)->nodeValue); - $this->assertEquals($result['serverId2'], $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(1)->nodeValue); + $this->assertEquals( + $result['serverId2'], + $xpath->query("//ns:Responses/ns:Add/ns:ServerId")->item(1)->nodeValue + ); // The server does not have to inform about a successful change $change = $xpath->query("//ns:Responses/ns:Change"); $this->assertEquals(0, $change->length); $this->assertEquals(0, $xpath->query("//ns:Commands")->length); return $tasksId; } /** * Test a sync key that shouldn't exist yet. * @depends testSyncTasks */ public function testInvalidSyncKey($tasksId) { $request = << 4 {$tasksId} clientId999 task1 0 2020-11-04T00:00:00.000Z 2020-11-03T23:00:00.000Z 16 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = self::fromWbxml($response->getBody()); $status = $dom->getElementsByTagName('Status'); $this->assertEquals("3", $status[0]->nodeValue); } /** * @doesNotPerformAssertions */ public function testCleanup(): void { $this->deleteTestUser(self::$user->email); } }