mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-24 03:28:54 +00:00
256 lines
11 KiB
PHP
256 lines
11 KiB
PHP
<?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);
|
|
}
|
|
}
|