diff --git a/tests/php/ApiKey/Controller/ApiKeyApiControllerTest.php b/tests/php/ApiKey/Controller/ApiKeyApiControllerTest.php new file mode 100644 index 000000000..7a4f1118e --- /dev/null +++ b/tests/php/ApiKey/Controller/ApiKeyApiControllerTest.php @@ -0,0 +1,116 @@ +keys = $this->createMock(ApiKeyService::class); + $this->api = new ApiKeyApiController($this->keys, new ApiResponse(['api_key'])); + } + + private static function key(int $id, string $description = ''): ApiKey + { + return ApiKey::fromRow(['id' => $id, 'api_key' => 'KEY-' . $id, 'description' => $description, 'access' => 'rw', + 'allow_from' => '192.0.2.1']); + } + + /** Messages written to the session as [type, msg] */ + private function messages(): array + { + return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []); + } + + public function testAddLogsWithoutKeyAndNamesTheKey(): void + { + $this->keys->method('create')->willReturn(self::key(4, '')); + + $this->assertTrue($this->api->addKey(['access' => 'rw', 'api_key' => 'chosen'])); + + $this->assertSame([[ + 'type' => 'success', + 'log' => ['api', 'add', 'key', ['access' => 'rw', 'api_key' => '*']], + 'msg' => ['api_key_added', '<crm>'], + ]], $_SESSION['return']); + } + + public function testErrorsBecomeDangerMessages(): void + { + $this->keys->method('create')->willReturnOnConsecutiveCalls( + $this->throwException(new UserError('ip_list_empty')), + $this->throwException(new PDOException('gone away')) + ); + + $this->assertFalse($this->api->addKey([])); + $this->assertFalse($this->api->addKey([])); + + $this->assertSame([['danger', 'ip_list_empty'], ['danger', ['mysql_error', 'gone away']]], $this->messages()); + } + + public function testEditReportsEachIdAndSkipsInvalidIds(): void + { + $this->keys->method('update')->willReturnCallback(fn(int $id, array $attr): ApiKey => ($id === 2) + ? throw new UserError('api_key_service_locked', ['API_KEY']) : self::key($id)); + + $this->assertTrue($this->api->editKeys(['1', '2', 'x'], ['active' => '0'])); + + $this->assertSame([ + ['success', ['api_key_modified', '1']], + ['danger', ['api_key_service_locked', 'API_KEY']], + ['danger', 'access_denied'], + ], $this->messages()); + } + + public function testEditAndDeleteNeedAdminOnce(): void + { + $this->keys->method('requireAdmin')->willThrowException(new AccessDenied()); + $this->keys->expects($this->never())->method('update'); + $this->keys->expects($this->never())->method('delete'); + + $this->assertFalse($this->api->editKeys(['1', '2'], [])); + $this->assertFalse($this->api->deleteKeys(['1', '2'])); + + $this->assertSame([['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages()); + } + + public function testDelete(): void + { + $this->keys->expects($this->once())->method('delete')->with(5)->willReturn(self::key(5, 'old')); + + $this->assertTrue($this->api->deleteKeys(['5'])); + + $this->assertSame([['success', ['api_key_deleted', 'old']]], $this->messages()); + } + + public function testGetReturnsApiShape(): void + { + $this->keys->method('all')->willReturn([self::key(1, 'crm')]); + $this->keys->method('get')->willReturnMap([[1, self::key(1, 'crm')], [2, null]]); + + $this->assertSame([[ + 'id' => 1, 'description' => 'crm', 'access' => 'rw', 'api_key' => 'KEY-1', 'allow_from' => ['192.0.2.1'], + 'skip_ip_check' => 0, 'service' => 0, 'active' => 1, 'created' => null, 'modified' => null, + ]], $this->api->getKeys()); + $this->assertSame('KEY-1', $this->api->getKey('1')['api_key']); + $this->assertSame([], $this->api->getKey('2')); + $this->assertSame([], $this->api->getKey('1.5')); + $this->assertSame([], $this->api->getKey('all')); + } +} diff --git a/tests/php/ApiKey/Service/ApiKeyServiceTest.php b/tests/php/ApiKey/Service/ApiKeyServiceTest.php new file mode 100644 index 000000000..4515a3568 --- /dev/null +++ b/tests/php/ApiKey/Service/ApiKeyServiceTest.php @@ -0,0 +1,255 @@ +repo = $this->createMock(ApiKeyRepository::class); + } + + private function service(?Context $context = null): ApiKeyService + { + return new ApiKeyService($this->repo, $context ?? FakeContext::admin()); + } + + private static function apiContext(string $access): Context + { + return new Context('admin', 'API', [], false, $access); + } + + private static function stored(array $row = []): ApiKey + { + return ApiKey::fromRow($row + ['id' => 3, 'api_key' => 'AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE', 'description' => 'crm', + 'access' => 'ro', 'allow_from' => '192.0.2.1,2001:db8::/64', 'skip_ip_check' => 0, 'service' => 0, 'active' => 1]); + } + + private function expectUserError(string|array $msg, callable $call): void + { + try { + $call(); + $this->fail('UserError ' . json_encode($msg) . ' expected'); + } catch (UserError $e) { + $this->assertSame($msg, $e->msg()); + } + } + + public static function deniedCallers(): array + { + return [ + 'domain admin' => [FakeContext::domainAdmin()], + 'user' => [FakeContext::user()], + 'system' => [Context::system()], + 'read-only API key' => [self::apiContext('ro')], + ]; + } + + #[DataProvider('deniedCallers')] + public function testOnlyAdminsMayReadOrWrite(Context $context): void + { + $this->repo->expects($this->never())->method('all'); + $this->repo->expects($this->never())->method('insert'); + $service = $this->service($context); + + $this->assertSame([], $service->all()); + $this->assertNull($service->get(3)); + $this->expectUserError('access_denied', fn() => $service->create(['access' => 'ro', 'skip_ip_check' => '1'])); + $this->expectUserError('access_denied', fn() => $service->delete(3)); + } + + public function testReadWriteApiKeyMayManageKeys(): void + { + $this->repo->method('all')->willReturn([self::stored()]); + + $this->assertCount(1, $this->service(self::apiContext('rw'))->all()); + } + + public function testCreateGeneratesKeyAndNormalisesNetworks(): void + { + $this->repo->expects($this->once())->method('insert')->with($this->callback(function (ApiKey $key): bool { + $this->assertMatchesRegularExpression(self::KEY_FORMAT, $key->key); + $this->assertSame(['192.0.2.1', '2001:db8::/64', '10.0.0.0/8'], $key->allowFrom); + $this->assertSame('rw', $key->access); + $this->assertSame('crm', $key->description); + $this->assertSame(0, $key->service); + // Hidden "0" + checked checkbox + $this->assertSame(1, $key->active); + $this->assertSame(0, $key->skipIpCheck); + return true; + }))->willReturn(7); + + $this->service()->create(['description' => ' crm ', 'access' => 'rw', 'active' => ['0', '1'], 'skip_ip_check' => '0', + 'allow_from' => "192.0.2.1\n2001:db8::/64, 10.0.0.0/8;192.0.2.1"]); + } + + public function testCreateAcceptsNetworkArray(): void + { + $this->repo->expects($this->once())->method('insert') + ->with($this->callback(fn(ApiKey $key): bool => $key->allowFrom === ['192.0.2.1', '::1'])); + + $this->service()->create(['access' => 'ro', 'allow_from' => ['192.0.2.1', ' ::1 ']]); + } + + public static function invalidInput(): array + { + return [ + 'access missing' => [['allow_from' => '192.0.2.1'], 'api_key_access_invalid'], + 'access unknown' => [['access' => 'admin', 'allow_from' => '192.0.2.1'], 'api_key_access_invalid'], + 'no network' => [['access' => 'ro'], 'ip_list_empty'], + 'only separators' => [['access' => 'ro', 'allow_from' => " ,\n"], 'ip_list_empty'], + 'hostname' => [['access' => 'ro', 'allow_from' => 'mail.example.org'], ['api_key_network_invalid', 'mail.example.org']], + 'v4 prefix too long' => [['access' => 'ro', 'allow_from' => '192.0.2.0/33'], ['api_key_network_invalid', '192.0.2.0/33']], + 'empty prefix' => [['access' => 'ro', 'allow_from' => '192.0.2.0/'], ['api_key_network_invalid', '192.0.2.0/']], + 'escaped' => [['access' => 'ro', 'allow_from' => ''], ['api_key_network_invalid', '<b>']], + 'description too long' => [['access' => 'ro', 'skip_ip_check' => '1', 'description' => str_repeat('x', 256)], 'api_key_description_invalid'], + 'description array' => [['access' => 'ro', 'skip_ip_check' => '1', 'description' => ['x']], 'api_key_description_invalid'], + ]; + } + + #[DataProvider('invalidInput')] + public function testCreateRejectsInvalidInput(array $input, string|array $msg): void + { + $this->repo->expects($this->never())->method('insert'); + + $this->expectUserError($msg, fn() => $this->service()->create($input)); + } + + public function testSkipIpCheckNeedsNoNetwork(): void + { + $this->repo->expects($this->once())->method('insert') + ->with($this->callback(fn(ApiKey $key): bool => $key->skipIpCheck === 1 && $key->allowFrom === [])); + + $this->service()->create(['access' => 'ro', 'skip_ip_check' => 'true']); + } + + public function testUpdateKeepsMissingAttributesAndKeyValue(): void + { + $this->repo->method('find')->with(3)->willReturn(self::stored()); + $this->repo->expects($this->once())->method('update')->with($this->callback(function (ApiKey $key): bool { + $this->assertSame('AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE', $key->key); + $this->assertSame(['192.0.2.1', '2001:db8::/64'], $key->allowFrom); + $this->assertSame('ro', $key->access); + $this->assertSame('crm', $key->description); + $this->assertSame(0, $key->active); + return true; + })); + + $this->service()->update(3, ['active' => '0']); + } + + public function testUpdateRegeneratesKey(): void + { + $this->repo->method('find')->willReturn(self::stored()); + $this->repo->expects($this->once())->method('update')->with($this->callback(fn(ApiKey $key): bool => + preg_match(self::KEY_FORMAT, $key->key) === 1 && $key->key !== 'AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE')); + + $this->service()->update(3, ['regen_key' => '1']); + } + + public function testDisablingSkipIpCheckNeedsNetworks(): void + { + $this->repo->method('find')->willReturn(self::stored(['allow_from' => '', 'skip_ip_check' => 1])); + $this->repo->expects($this->never())->method('update'); + + $this->expectUserError('ip_list_empty', fn() => $this->service()->update(3, ['skip_ip_check' => '0'])); + } + + public function testServiceKeysAreLocked(): void + { + $this->repo->method('find')->willReturn(self::stored(['service' => 1, 'description' => 'API_KEY'])); + $this->repo->expects($this->never())->method('update'); + $this->repo->expects($this->never())->method('delete'); + + $this->expectUserError(['api_key_service_locked', 'API_KEY'], fn() => $this->service()->update(3, ['regen_key' => '1'])); + $this->expectUserError(['api_key_service_locked', 'API_KEY'], fn() => $this->service()->delete(3)); + } + + public function testMissingKeyIsAccessDenied(): void + { + $this->repo->method('find')->willReturn(null); + + $this->expectUserError('access_denied', fn() => $this->service()->update(99, [])); + $this->expectUserError('access_denied', fn() => $this->service()->delete(99)); + } + + public function testDeleteReturnsDeletedKey(): void + { + $this->repo->method('find')->willReturn(self::stored()); + $this->repo->expects($this->once())->method('delete')->with(3); + + $this->assertSame('crm', $this->service()->delete(3)->description); + } + + public function testSyncMirrorsBothServiceKeys(): void + { + $this->repo->expects($this->once())->method('syncService')->with($this->callback(function (array $keys): bool { + $this->assertCount(2, $keys); + [$rw, $ro] = $keys; + $this->assertSame(['RW-KEY', 'API_KEY', 'rw', 1, ['192.0.2.1', '2001:db8::/64']], + [$rw->key, $rw->description, $rw->access, $rw->service, $rw->allowFrom]); + $this->assertSame(['RO-KEY', 'API_KEY_READ_ONLY', 'ro', 1, 1, 0], + [$ro->key, $ro->description, $ro->access, $ro->service, $ro->active, $ro->skipIpCheck]); + return true; + })); + + $warnings = $this->service(Context::system())->syncServiceKeys([ + 'API_KEY' => 'RW-KEY', 'API_KEY_READ_ONLY' => ' RO-KEY ', 'API_ALLOW_FROM' => '192.0.2.1,2001:db8::/64,nonsense', + ]); + + $this->assertSame(['API_ALLOW_FROM: skipped invalid IP/network nonsense'], $warnings); + } + + public static function unsetEnv(): array + { + return [ + 'compose defaults' => [['API_KEY' => 'invalid', 'API_KEY_READ_ONLY' => 'invalid', 'API_ALLOW_FROM' => 'invalid']], + 'empty' => [['API_KEY' => '', 'API_ALLOW_FROM' => '192.0.2.1']], + 'missing' => [[]], + ]; + } + + #[DataProvider('unsetEnv')] + public function testSyncWithoutServiceKeysRemovesThem(array $env): void + { + $this->repo->expects($this->once())->method('syncService')->with([]); + + $this->assertSame([], $this->service(Context::system())->syncServiceKeys($env)); + } + + public function testSyncSkipsInvalidKeys(): void + { + $this->repo->expects($this->once())->method('syncService') + ->with($this->callback(fn(array $keys): bool => count($keys) === 1 && $keys[0]->description === 'API_KEY')); + + $warnings = $this->service(Context::system())->syncServiceKeys([ + 'API_KEY' => 'SAME-KEY', 'API_KEY_READ_ONLY' => 'SAME-KEY', 'API_ALLOW_FROM' => '192.0.2.1', + ]); + $this->assertSame(['API_KEY_READ_ONLY: same value as API_KEY, key ignored'], $warnings); + + $this->repo = $this->createMock(ApiKeyRepository::class); + $this->repo->expects($this->once())->method('syncService')->with([]); + $warnings = $this->service(Context::system())->syncServiceKeys(['API_KEY' => 'bad"key', 'API_KEY_READ_ONLY' => 'RO-KEY', + 'API_ALLOW_FROM' => 'invalid']); + $this->assertSame([ + 'API_KEY: invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored', + 'API_KEY_READ_ONLY: API_ALLOW_FROM contains no valid IP/network, key ignored', + ], $warnings); + } +} diff --git a/tests/php/Core/ContextTest.php b/tests/php/Core/ContextTest.php index 654065887..19217391f 100644 --- a/tests/php/Core/ContextTest.php +++ b/tests/php/Core/ContextTest.php @@ -58,6 +58,22 @@ class ContextTest extends TestCase $this->assertFalse(Context::fromSession(['acl' => 'not an array'])->hasAcl('syncjobs')); } + public function testApiAccessComesFromTheApiKeySession(): void + { + $ro = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'ro']); + $rw = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'rw']); + $login = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api_access' => 'ro']); + + $this->assertSame('ro', $ro->apiAccess); + $this->assertTrue($ro->isReadOnlyApi()); + $this->assertSame('rw', $rw->apiAccess); + $this->assertFalse($rw->isReadOnlyApi()); + $this->assertNull($login->apiAccess); + $this->assertFalse($login->isReadOnlyApi()); + // Unknown access values are read-only + $this->assertTrue(Context::fromSession(['mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'x'])->isReadOnlyApi()); + } + public function testSystemContextIsNoAccount(): void { $context = Context::system();