mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-24 11:38:54 +00:00
[Web][Tests] Add API key tests
This commit is contained in:
3 files changed
+387
No files matched your search
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\ApiKey\Controller;
|
||||
|
||||
use Mailcow\ApiKey\Controller\ApiKeyApiController;
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\ApiKey\Service\ApiKeyService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiKeyApiControllerTest extends TestCase
|
||||
{
|
||||
private ApiKeyService&MockObject $keys;
|
||||
private ApiKeyApiController $api;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$this->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, '<crm>'));
|
||||
|
||||
$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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\ApiKey\Service;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\ApiKey\Repository\ApiKeyRepository;
|
||||
use Mailcow\ApiKey\Service\ApiKeyService;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiKeyServiceTest extends TestCase
|
||||
{
|
||||
private const KEY_FORMAT = '/^([0-9A-F]{6}-){4}[0-9A-F]{6}$/';
|
||||
|
||||
private ApiKeyRepository&MockObject $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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' => '<b>'], ['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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user