mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-22 10:48:13 +00:00
[Web][Tests] Add PHPUnit setup and imapsync module tests
This commit is contained in:
18 files changed
+3948
-1
No files matched your search
+3
-1
@@ -78,4 +78,6 @@ refresh_images.sh
|
||||
update_diffs/
|
||||
create_cold_standby.sh
|
||||
!data/conf/nginx/mailcow_auth.conf
|
||||
data/conf/postfix/postfix-tlspol
|
||||
data/conf/postfix/postfix-tlspol
|
||||
tests/php/vendor/
|
||||
tests/php/.phpunit.cache/
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Runs the PHPUnit tests of data/web/inc/lib/mailcow (tests/php) in throwaway containers.
|
||||
# Dev only: PHPUnit is installed into tests/php/vendor, never into the production vendor directory.
|
||||
# Usage: helper-scripts/dev_tests/run_php_unit_tests.sh [phpunit arguments, e.g. --filter JobServiceTest]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TESTS_DIR="${REPO_DIR}/tests/php"
|
||||
|
||||
docker run --rm -u "$(id -u):$(id -g)" -v "${TESTS_DIR}:/app" -w /app composer:2 \
|
||||
install --no-interaction --no-progress --quiet
|
||||
|
||||
docker run --rm -u "$(id -u):$(id -g)" -v "${REPO_DIR}:/app" -w /app/tests/php php:8.2-cli \
|
||||
php vendor/bin/phpunit "$@"
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Core;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiResponseTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
public function testMessagesUseTheMailcowFormat(): void
|
||||
{
|
||||
$response = new ApiResponse();
|
||||
|
||||
$this->assertTrue($response->success(['mailbox_modified', 'a@example.org'], ['syncjob', 'add', 'job', []]));
|
||||
$this->assertFalse($response->danger('access_denied', ['syncjob', 'edit', 'job', []]));
|
||||
|
||||
$this->assertSame([
|
||||
['type' => 'success', 'log' => ['syncjob', 'add', 'job', []], 'msg' => ['mailbox_modified', 'a@example.org']],
|
||||
['type' => 'danger', 'log' => ['syncjob', 'edit', 'job', []], 'msg' => 'access_denied'],
|
||||
], $response->messages());
|
||||
}
|
||||
|
||||
public function testSecretsAreMaskedInArrayLogParts(): void
|
||||
{
|
||||
$response = new ApiResponse(['password1', 'oauth_client_secret']);
|
||||
|
||||
$response->success('ok', ['syncjob', 'add', 'job', [
|
||||
'password1' => 'secret',
|
||||
'oauth_client_secret' => '',
|
||||
'user1' => 'remote',
|
||||
'nested' => ['password1' => 'kept'],
|
||||
]]);
|
||||
|
||||
$this->assertSame(['syncjob', 'add', 'job', [
|
||||
'password1' => '*',
|
||||
'oauth_client_secret' => '',
|
||||
'user1' => 'remote',
|
||||
'nested' => ['password1' => 'kept'],
|
||||
]], $response->messages()[0]['log']);
|
||||
}
|
||||
|
||||
public function testNullSecretStaysNull(): void
|
||||
{
|
||||
$response = new ApiResponse(['password1']);
|
||||
|
||||
$response->danger('x', ['syncjob', 'add', 'job', ['password1' => null]]);
|
||||
|
||||
$this->assertSame(['password1' => null], $response->messages()[0]['log'][3]);
|
||||
}
|
||||
|
||||
public function testUserErrorBecomesDangerMessage(): void
|
||||
{
|
||||
$response = new ApiResponse();
|
||||
|
||||
$this->assertFalse($response->error(new UserError('object_exists', ['name']), ['l']));
|
||||
$response->error(new AccessDenied(), ['l']);
|
||||
|
||||
$this->assertSame(['object_exists', 'name'], $response->messages()[0]['msg']);
|
||||
$this->assertSame('danger', $response->messages()[0]['type']);
|
||||
$this->assertSame('access_denied', $response->messages()[1]['msg']);
|
||||
}
|
||||
|
||||
public function testFlushAppendsToSessionAndStartsOver(): void
|
||||
{
|
||||
$_SESSION['return'] = [['type' => 'success', 'log' => [], 'msg' => 'earlier']];
|
||||
$response = new ApiResponse();
|
||||
$response->success('first', ['l']);
|
||||
$response->danger('second', ['l']);
|
||||
|
||||
$response->flush();
|
||||
$response->flush();
|
||||
|
||||
$this->assertSame(['earlier', 'first', 'second'], array_column($_SESSION['return'], 'msg'));
|
||||
$this->assertSame([], $response->messages());
|
||||
}
|
||||
|
||||
public function testFlushWithoutMessagesLeavesSessionUntouched(): void
|
||||
{
|
||||
(new ApiResponse())->flush();
|
||||
|
||||
$this->assertArrayNotHasKey('return', $_SESSION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Core;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Tests\Support\LegacyAclCalls;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../Support/legacy_acl_functions.php';
|
||||
|
||||
class ContextTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
LegacyAclCalls::$calls = [];
|
||||
}
|
||||
|
||||
public function testFromSessionMapsRoleUsernameAclAndAccessAll(): void
|
||||
{
|
||||
$context = Context::fromSession([
|
||||
'mailcow_cc_role' => 'domainadmin',
|
||||
'mailcow_cc_username' => 'da',
|
||||
'acl' => ['syncjobs' => '1', 'spam_score' => '0'],
|
||||
'access_all_exception' => '1',
|
||||
]);
|
||||
|
||||
$this->assertSame('domainadmin', $context->role);
|
||||
$this->assertSame('da', $context->username);
|
||||
$this->assertTrue($context->isDomainAdmin());
|
||||
$this->assertFalse($context->isAdmin());
|
||||
$this->assertFalse($context->isUser());
|
||||
$this->assertTrue($context->hasAcl('syncjobs'));
|
||||
$this->assertFalse($context->hasAcl('spam_score'));
|
||||
$this->assertFalse($context->hasAcl('missing'));
|
||||
$this->assertTrue($context->accessAll);
|
||||
}
|
||||
|
||||
public function testEmptySessionHasNoRights(): void
|
||||
{
|
||||
$context = Context::fromSession([]);
|
||||
|
||||
$this->assertSame('', $context->role);
|
||||
$this->assertNull($context->username);
|
||||
$this->assertFalse($context->isAdmin() || $context->isDomainAdmin() || $context->isUser());
|
||||
$this->assertFalse($context->hasAcl('syncjobs'));
|
||||
$this->assertFalse($context->accessAll);
|
||||
}
|
||||
|
||||
public function testAclAndAccessAllAcceptIntegerOneLikeTheLegacyCode(): void
|
||||
{
|
||||
$context = Context::fromSession(['mailcow_cc_role' => 'user', 'acl' => ['syncjobs' => 1], 'access_all_exception' => 1]);
|
||||
|
||||
$this->assertTrue($context->hasAcl('syncjobs'));
|
||||
$this->assertTrue($context->accessAll);
|
||||
$this->assertFalse(Context::fromSession(['access_all_exception' => '0'])->accessAll);
|
||||
$this->assertFalse(Context::fromSession(['acl' => 'not an array'])->hasAcl('syncjobs'));
|
||||
}
|
||||
|
||||
public function testSystemContextIsNoAccount(): void
|
||||
{
|
||||
$context = Context::system();
|
||||
|
||||
$this->assertSame('system', $context->role);
|
||||
$this->assertNull($context->username);
|
||||
$this->assertFalse($context->isAdmin());
|
||||
$this->assertFalse($context->hasAcl('syncjobs'));
|
||||
}
|
||||
|
||||
public function testBridgePassesUsernameAndRoleToTheLegacyFunctions(): void
|
||||
{
|
||||
$context = new Context('user', 'user@example.org');
|
||||
|
||||
$this->assertTrue($context->canAccessMailbox('allowed@example.org'));
|
||||
$this->assertFalse($context->canAccessMailbox('other@example.org'));
|
||||
$this->assertTrue($context->canAccessDomain('example.org'));
|
||||
$this->assertSame([
|
||||
['mailbox', 'user@example.org', 'user', 'allowed@example.org'],
|
||||
['mailbox', 'user@example.org', 'user', 'other@example.org'],
|
||||
['domain', 'user@example.org', 'user', 'example.org'],
|
||||
], LegacyAclCalls::$calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Imapsync\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Controller\ImapsyncApiController;
|
||||
use Mailcow\Imapsync\Model\Job;
|
||||
use Mailcow\Imapsync\Model\Settings;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Service\JobService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApiControllerTest extends TestCase
|
||||
{
|
||||
private SourceService&MockObject $sources;
|
||||
private JobService&MockObject $jobs;
|
||||
private ImapsyncApiController $api;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$this->sources = $this->createMock(SourceService::class);
|
||||
$this->jobs = $this->createMock(JobService::class);
|
||||
$this->api = new ImapsyncApiController($this->sources, $this->jobs, new ApiResponse(['password1', 'oauth_client_secret']));
|
||||
}
|
||||
|
||||
private static function job(string $user2 = 'local@example.org', array $row = []): Job
|
||||
{
|
||||
return Job::fromRow($row + ['id' => 1, 'user2' => $user2, 'user1' => 'remote', 'source_id' => 5]);
|
||||
}
|
||||
|
||||
private static function source(string $name = 'src'): Source
|
||||
{
|
||||
return Source::fromRow(['id' => 5, 'name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 testAddJobSuccessIsLoggedWithoutSecrets(): void
|
||||
{
|
||||
$this->jobs->method('create')->willReturn(self::job('new@example.org'));
|
||||
|
||||
$this->assertTrue($this->api->addJob(['username' => 'new@example.org', 'password1' => 'pw']));
|
||||
|
||||
$this->assertSame([[
|
||||
'type' => 'success',
|
||||
'log' => ['syncjob', 'add', 'job', ['username' => 'new@example.org', 'password1' => '*']],
|
||||
'msg' => ['mailbox_modified', 'new@example.org'],
|
||||
]], $_SESSION['return']);
|
||||
}
|
||||
|
||||
public function testUserErrorAndDatabaseErrorBecomeDangerMessages(): void
|
||||
{
|
||||
$this->jobs->method('create')->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new UserError('object_exists', ['src / remote'])),
|
||||
$this->throwException(new PDOException('deadlock'))
|
||||
);
|
||||
|
||||
$this->assertFalse($this->api->addJob([]));
|
||||
$this->assertFalse($this->api->addJob([]));
|
||||
|
||||
$this->assertSame([['danger', ['object_exists', 'src / remote']], ['danger', ['mysql_error', 'deadlock']]], $this->messages());
|
||||
}
|
||||
|
||||
public function testAddSource(): void
|
||||
{
|
||||
$this->sources->method('create')->willReturn(self::source('new'));
|
||||
|
||||
$this->assertTrue($this->api->addSource(['name' => 'new', 'oauth_client_secret' => 's']));
|
||||
|
||||
$this->assertSame([['success', ['imapsync_source_added', 'new']]], $this->messages());
|
||||
$this->assertSame('*', $_SESSION['return'][0]['log'][3]['oauth_client_secret']);
|
||||
}
|
||||
|
||||
public function testEditJobsKeepsBatchMessageOrder(): void
|
||||
{
|
||||
$this->jobs->method('update')->willReturnCallback(function (int $id, array $data): Job {
|
||||
if ($id === 2) {
|
||||
throw new UserError('imapsync_source_unavailable');
|
||||
}
|
||||
return self::job('mb' . $id . '@example.org');
|
||||
});
|
||||
|
||||
$this->assertTrue($this->api->editJobs([1, 'x', 2, '3', 1.5], ['active' => 0]));
|
||||
|
||||
$this->assertSame([
|
||||
['success', ['mailbox_modified', 'mb1@example.org']],
|
||||
['danger', 'access_denied'],
|
||||
['danger', 'imapsync_source_unavailable'],
|
||||
['success', ['mailbox_modified', 'mb3@example.org']],
|
||||
['danger', 'access_denied'],
|
||||
], $this->messages());
|
||||
$this->assertSame(['syncjob', 'edit', 'job', ['id' => [1, 'x', 2, '3', 1.5], 'active' => 0]], $_SESSION['return'][0]['log']);
|
||||
}
|
||||
|
||||
public function testEditJobsIdInAttributesWinsLikeArrayMerge(): void
|
||||
{
|
||||
$this->jobs->expects($this->once())->method('update')->with(7, ['id' => 7])->willReturn(self::job());
|
||||
|
||||
$this->assertTrue($this->api->editJobs([1, 2], ['id' => 7]));
|
||||
}
|
||||
|
||||
public function testEditJobsWithoutAclIsOneRejection(): void
|
||||
{
|
||||
$this->jobs->method('requireAcl')->willThrowException(new AccessDenied());
|
||||
$this->jobs->expects($this->never())->method('update');
|
||||
|
||||
$this->assertFalse($this->api->editJobs([1, 2], []));
|
||||
$this->assertSame([['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testDatabaseErrorStopsTheBatch(): void
|
||||
{
|
||||
$this->jobs->method('update')->willReturnOnConsecutiveCalls(self::job(), $this->throwException(new PDOException('gone')));
|
||||
|
||||
$this->assertFalse($this->api->editJobs([1, 2, 3], []));
|
||||
$this->assertSame([['success', ['mailbox_modified', 'local@example.org']], ['danger', ['mysql_error', 'gone']]], $this->messages());
|
||||
}
|
||||
|
||||
public function testEditJobsWithoutItemsDoesNothing(): void
|
||||
{
|
||||
$this->jobs->expects($this->never())->method('update');
|
||||
|
||||
$this->assertTrue($this->api->editJobs(null, ['active' => 1]));
|
||||
$this->assertSame([], $this->messages());
|
||||
}
|
||||
|
||||
public function testRunNextReportsRunningJobsThenTheMove(): void
|
||||
{
|
||||
$this->jobs->expects($this->once())->method('runNext')->with([4, 1, 2])->willReturn(['running' => [4], 'moved' => 2]);
|
||||
|
||||
$this->assertTrue($this->api->runNext(['4', 'x', 1.7, 2]));
|
||||
$this->assertSame([['danger', ['imapsync_job_running', 4]], ['success', ['imapsync_run_next_done', 2]]], $this->messages());
|
||||
$this->assertSame(['syncjob', 'edit', 'run_next', ['id' => ['4', 'x', 1.7, 2]]], $_SESSION['return'][0]['log']);
|
||||
}
|
||||
|
||||
public function testRunNextWithoutMoveReturnsFalse(): void
|
||||
{
|
||||
$this->jobs->method('runNext')->willReturnOnConsecutiveCalls(['running' => [], 'moved' => 0], $this->throwException(new AccessDenied()));
|
||||
|
||||
$this->assertFalse($this->api->runNext([9]));
|
||||
$this->assertSame([], $this->messages());
|
||||
$this->assertFalse($this->api->runNext([9]));
|
||||
$this->assertSame([['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testEditSourcesSkipsNonNumericIds(): void
|
||||
{
|
||||
$this->sources->method('update')->willReturnCallback(function (int $id): Source {
|
||||
if ($id === 3) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return self::source('s' . $id);
|
||||
});
|
||||
|
||||
$this->assertTrue($this->api->editSources(['a', 2, 3, '1.5'], ['name' => 'x']));
|
||||
$this->assertSame([['success', ['imapsync_source_modified', 's2']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testEditSourcesNeedsAnEditor(): void
|
||||
{
|
||||
$this->sources->method('requireEditor')->willThrowException(new AccessDenied());
|
||||
|
||||
$this->assertFalse($this->api->editSources([1], []));
|
||||
$this->assertSame([['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testRefreshTokenUsesTheFirstId(): void
|
||||
{
|
||||
$this->sources->expects($this->once())->method('refreshToken')->with(6)->willReturn(self::source('m365'));
|
||||
|
||||
$this->assertTrue($this->api->refreshSourceToken(['6', '7']));
|
||||
$this->assertFalse($this->api->refreshSourceToken([]));
|
||||
$this->assertFalse($this->api->refreshSourceToken(null));
|
||||
|
||||
$this->assertSame([['success', ['imapsync_source_token_refreshed', 'm365']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
|
||||
$this->assertSame(['syncjob', 'edit', 'refresh_token', ['id' => ['6', '7']]], $_SESSION['return'][0]['log']);
|
||||
}
|
||||
|
||||
public function testEditSettings(): void
|
||||
{
|
||||
$this->jobs->method('saveSettings')->willReturnOnConsecutiveCalls(new Settings(), $this->throwException(new UserError('imapsync_max_parallel_invalid')));
|
||||
|
||||
$this->assertTrue($this->api->editSettings(['max_parallel' => 2]));
|
||||
$this->assertFalse($this->api->editSettings(['max_parallel' => 0]));
|
||||
$this->assertSame([['success', 'max_parallel_saved'], ['danger', 'imapsync_max_parallel_invalid']], $this->messages());
|
||||
$this->assertSame(['syncjob', 'edit', 'settings', ['max_parallel' => 2]], $_SESSION['return'][0]['log']);
|
||||
}
|
||||
|
||||
public function testDeleteJobsUsesTheRequestIdInTheMessage(): void
|
||||
{
|
||||
$this->jobs->method('delete')->willReturnCallback(function (int $id): void {
|
||||
if ($id === 8) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
});
|
||||
|
||||
$this->assertTrue($this->api->deleteJobs(['5', 8, 'x']));
|
||||
$this->assertSame([['success', ['deleted_syncjob', '5']], ['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testDeleteSources(): void
|
||||
{
|
||||
$this->sources->method('delete')->willReturnCallback(function (int $id): Source {
|
||||
if ($id === 2) {
|
||||
throw new UserError('imapsync_source_in_use', ['busy']);
|
||||
}
|
||||
return self::source('gone');
|
||||
});
|
||||
|
||||
$this->assertTrue($this->api->deleteSources([1, 'x', 2]));
|
||||
$this->assertSame([['success', ['imapsync_source_deleted', 'gone']], ['danger', ['imapsync_source_in_use', 'busy']]], $this->messages());
|
||||
|
||||
$_SESSION = [];
|
||||
$this->sources->method('requireAcl')->willThrowException(new AccessDenied());
|
||||
$this->assertFalse($this->api->deleteSources([1]));
|
||||
$this->assertSame([['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testReadRoutes(): void
|
||||
{
|
||||
$source = self::source();
|
||||
$this->jobs->method('customParamOptions')->willReturn(['debug' => false]);
|
||||
$this->jobs->method('getSettings')->willReturn(new Settings());
|
||||
$this->sources->method('visibleIds')->willReturn([5, 6]);
|
||||
$this->sources->method('get')->willReturnMap([[5, $source], [6, null]]);
|
||||
$this->sources->method('toArray')->with($source)->willReturn(['id' => 5]);
|
||||
|
||||
$this->assertSame(['debug' => false], $this->api->getOptions());
|
||||
$this->assertSame(['max_parallel' => 1, 'max_bytes_per_second' => 0, 'max_kb_per_second' => 0], $this->api->getSettings());
|
||||
$this->assertSame([['id' => 5]], $this->api->getSources());
|
||||
$this->assertSame(['id' => 5], $this->api->getSource('5'));
|
||||
$this->assertSame([], $this->api->getSource('abc'));
|
||||
$this->assertSame([], $this->api->getSource(6));
|
||||
$this->assertSame([], $this->messages());
|
||||
}
|
||||
|
||||
public function testSettingsAreEmptyForNonAdmins(): void
|
||||
{
|
||||
$this->jobs->method('getSettings')->willThrowException(new AccessDenied());
|
||||
|
||||
$this->assertSame([], $this->api->getSettings());
|
||||
}
|
||||
|
||||
public function testJobRoutesHonourNoLog(): void
|
||||
{
|
||||
$job = self::job('local@example.org', ['returned_text' => 'log text', 'password1' => 'secret']);
|
||||
$this->jobs->method('idsForMailbox')->with('local@example.org')->willReturn([1]);
|
||||
$this->jobs->expects($this->exactly(2))->method('get')
|
||||
->willReturnCallback(fn(int $id, bool $withLog): Job => $job);
|
||||
|
||||
$withLog = $this->api->getJobs('local@example.org', null);
|
||||
$withoutLog = $this->api->getJob('1', 'no_log,with_password');
|
||||
|
||||
$this->assertSame('log text', $withLog[0]['log']);
|
||||
$this->assertSame('', $withoutLog['log']);
|
||||
$this->assertArrayNotHasKey('password1', $withLog[0]);
|
||||
$this->assertArrayNotHasKey('returned_text', $withLog[0]);
|
||||
}
|
||||
|
||||
public function testAllJobsForAdminsAndOthers(): void
|
||||
{
|
||||
$jobs = [
|
||||
1 => self::job('a@example.org', ['id' => 1, 'prio' => 0, 'last_run' => '2026-10-01 10:00:00']),
|
||||
2 => self::job('b@example.org', ['id' => 2, 'prio' => 0]),
|
||||
3 => self::job('a@example.org', ['id' => 3, 'prio' => 5, 'last_run' => '2026-10-02 10:00:00']),
|
||||
4 => self::job('b@example.org', ['id' => 4, 'prio' => 0, 'last_run' => '2026-09-01 10:00:00']),
|
||||
];
|
||||
$this->jobs->method('canListAll')->willReturnOnConsecutiveCalls(true, false);
|
||||
$this->jobs->method('allIds')->willReturn([3, 2, 4, 1]);
|
||||
$this->jobs->method('idsForMailbox')->willReturnMap([['a@example.org', [3, 1]], ['b@example.org', [2, 4]]]);
|
||||
$this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => $jobs[$id] ?? null);
|
||||
|
||||
$this->assertSame([3, 2, 4, 1], array_column($this->api->getAllJobs(null, []), 'id'));
|
||||
$this->assertSame([3, 2, 4, 1], array_column($this->api->getAllJobs('no_log', ['a@example.org', 'b@example.org']), 'id'));
|
||||
}
|
||||
|
||||
public function testAccountChangedOutsideTransactionReportsDatabaseErrors(): void
|
||||
{
|
||||
$this->sources->expects($this->once())->method('onAccountRenamed')->with('old@example.org', 'new@example.org');
|
||||
$this->sources->method('onAccountDeleted')->willThrowException(new PDOException('locked'));
|
||||
|
||||
$this->api->accountChanged('old@example.org', 'new@example.org');
|
||||
$this->assertSame([], $this->messages());
|
||||
|
||||
$this->api->accountChanged('old@example.org');
|
||||
$this->assertSame([[
|
||||
'type' => 'danger',
|
||||
'log' => ['imapsync_account_changed', 'old@example.org', null],
|
||||
'msg' => ['mysql_error', 'locked'],
|
||||
]], $_SESSION['return']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Imapsync\Http;
|
||||
|
||||
use Mailcow\Imapsync\Http\OAuthException;
|
||||
use Mailcow\Tests\Support\FakeOAuthClient;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class OAuthClientTest extends TestCase
|
||||
{
|
||||
private FakeOAuthClient $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = new FakeOAuthClient();
|
||||
}
|
||||
|
||||
public static function malformedUrls(): array
|
||||
{
|
||||
return [
|
||||
'http' => ['http://login.example.org/token'],
|
||||
'file' => ['file:///etc/passwd'],
|
||||
'gopher' => ['gopher://login.example.org/'],
|
||||
'userinfo' => ['https://user:pass@login.example.org/token'],
|
||||
'user only' => ['https://user@login.example.org/token'],
|
||||
'no host' => ['https:///token'],
|
||||
'invalid hostname' => ['https://login_example.org/token'],
|
||||
'space' => ['https://login.example.org/a b'],
|
||||
'control character' => ["https://login.example.org/\x01"],
|
||||
'too long' => ['https://login.example.org/' . str_repeat('a', 480)],
|
||||
'empty' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('malformedUrls')]
|
||||
public function testMalformedUrlsAreNeverAllowed(string $url): void
|
||||
{
|
||||
$this->assertFalse($this->client->isAllowed($url, false));
|
||||
$this->assertFalse($this->client->isAllowed($url, true));
|
||||
}
|
||||
|
||||
public function testNonStrictModeDoesNotResolve(): void
|
||||
{
|
||||
$this->assertTrue($this->client->isAllowed('https://login.example.org/token', false));
|
||||
$this->assertTrue($this->client->isAllowed('https://localhost.localdomain:8443/token?x=1', false));
|
||||
$this->assertSame([], $this->client->resolved);
|
||||
}
|
||||
|
||||
public static function blockedAddresses(): array
|
||||
{
|
||||
return [
|
||||
'loopback' => ['127.0.0.1'],
|
||||
'this network' => ['0.0.0.0'],
|
||||
'private 10/8' => ['10.1.2.3'],
|
||||
'private 172.16/12' => ['172.31.255.255'],
|
||||
'private 192.168/16' => ['192.168.0.1'],
|
||||
'link-local' => ['169.254.169.254'],
|
||||
'CGNAT start' => ['100.64.0.0'],
|
||||
'CGNAT end' => ['100.127.255.255'],
|
||||
'benchmarking start' => ['198.18.0.1'],
|
||||
'benchmarking end' => ['198.19.255.255'],
|
||||
'multicast' => ['224.0.0.1'],
|
||||
'reserved 240/4' => ['240.0.0.1'],
|
||||
'broadcast' => ['255.255.255.255'],
|
||||
'IPv6 loopback' => ['::1'],
|
||||
'IPv6 unspecified' => ['::'],
|
||||
'IPv6 link-local' => ['fe80::1'],
|
||||
'IPv6 ULA' => ['fd00::1'],
|
||||
'IPv6 multicast' => ['ff02::1'],
|
||||
'IPv4-mapped' => ['::ffff:127.0.0.1'],
|
||||
'NAT64' => ['64:ff9b::a00:1'],
|
||||
'6to4' => ['2002:a00:1::1'],
|
||||
'Teredo' => ['2001:0:4136:e378::1'],
|
||||
'documentation' => ['2001:db8::1'],
|
||||
'outside 2000::/3' => ['4000::1'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('blockedAddresses')]
|
||||
public function testStrictModeBlocksNonPublicAddresses(string $ip): void
|
||||
{
|
||||
$this->client->addresses['login.example.org'] = [$ip];
|
||||
|
||||
$this->assertFalse($this->client->isAllowed('https://login.example.org/token', true));
|
||||
}
|
||||
|
||||
public static function publicAddresses(): array
|
||||
{
|
||||
return [
|
||||
'IPv4' => ['8.8.8.8'],
|
||||
'just below CGNAT' => ['100.63.255.255'],
|
||||
'just above CGNAT' => ['100.128.0.1'],
|
||||
'just below benchmarking' => ['198.17.255.255'],
|
||||
'just above benchmarking' => ['198.20.0.1'],
|
||||
'just below multicast' => ['223.255.255.254'],
|
||||
'IPv6 global' => ['2606:4700:4700::1111'],
|
||||
'IPv6 global 2a00' => ['2a00:1450:4001::1'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('publicAddresses')]
|
||||
public function testStrictModeAllowsPublicAddresses(string $ip): void
|
||||
{
|
||||
$this->client->addresses['login.example.org'] = [$ip];
|
||||
|
||||
$this->assertTrue($this->client->isAllowed('https://login.example.org/token', true));
|
||||
}
|
||||
|
||||
public function testStrictModeNeedsEveryAddressPublic(): void
|
||||
{
|
||||
$this->client->addresses['login.example.org'] = ['8.8.8.8', '10.0.0.1'];
|
||||
$this->assertFalse($this->client->isAllowed('https://login.example.org/token', true));
|
||||
|
||||
$this->client->addresses['login.example.org'] = [];
|
||||
$this->assertFalse($this->client->isAllowed('https://login.example.org/token', true));
|
||||
}
|
||||
|
||||
public function testStrictRequestIsPinnedToTheCheckedAddresses(): void
|
||||
{
|
||||
$this->client->addresses['login.example.org'] = ['8.8.8.8', '8.8.8.8', '2606:4700:4700::1111'];
|
||||
$this->client->response = ['{"access_token":"t"}', 200];
|
||||
|
||||
$this->client->request('https://Login.Example.org:8443/token', true, ['grant_type' => 'client_credentials']);
|
||||
|
||||
$this->assertSame(['login.example.org:8443:8.8.8.8,[2606:4700:4700::1111]'], $this->client->sent[0][CURLOPT_RESOLVE]);
|
||||
}
|
||||
|
||||
public function testRequestOptionsAllowHttpsOnlyWithoutRedirects(): void
|
||||
{
|
||||
$this->client->response = ['{"ok":true}', 200];
|
||||
|
||||
$result = $this->client->request('https://login.example.org/token', false, ['a' => 'b c', 'd' => 'e'], ['Authorization: Bearer x']);
|
||||
|
||||
$options = $this->client->sent[0];
|
||||
$this->assertSame(['ok' => true], $result);
|
||||
$this->assertArrayNotHasKey(CURLOPT_RESOLVE, $options);
|
||||
$this->assertSame(CURLPROTO_HTTPS, $options[CURLOPT_PROTOCOLS]);
|
||||
$this->assertSame(CURLPROTO_HTTPS, $options[CURLOPT_REDIR_PROTOCOLS]);
|
||||
$this->assertFalse($options[CURLOPT_FOLLOWLOCATION]);
|
||||
$this->assertTrue($options[CURLOPT_SSL_VERIFYPEER]);
|
||||
$this->assertSame(2, $options[CURLOPT_SSL_VERIFYHOST]);
|
||||
$this->assertSame(1048576, $options[CURLOPT_MAXFILESIZE]);
|
||||
$this->assertTrue($options[CURLOPT_POST]);
|
||||
$this->assertSame('a=b+c&d=e', $options[CURLOPT_POSTFIELDS]);
|
||||
$this->assertSame(['Accept: application/json', 'Authorization: Bearer x'], $options[CURLOPT_HTTPHEADER]);
|
||||
}
|
||||
|
||||
public function testGetRequestHasNoBody(): void
|
||||
{
|
||||
$this->client->request('https://login.example.org/userinfo', false);
|
||||
|
||||
$this->assertArrayNotHasKey(CURLOPT_POST, $this->client->sent[0]);
|
||||
}
|
||||
|
||||
public function testDisallowedEndpointIsNeverRequested(): void
|
||||
{
|
||||
$this->client->addresses['internal.example.org'] = ['10.0.0.1'];
|
||||
|
||||
$this->assertSame('endpoint not allowed', $this->requestError('https://internal.example.org/token', true));
|
||||
$this->assertSame([], $this->client->sent);
|
||||
}
|
||||
|
||||
public static function failedResponses(): array
|
||||
{
|
||||
return [
|
||||
'connection error' => [[false, 0], 'connection failed'],
|
||||
'provider error fields' => [['{"error":"invalid_client","error_description":"Bad secret","trace":"x"}', 401], 'HTTP 401: invalid_client - Bad secret'],
|
||||
'html body is never shown' => [['<html><body>internal details</body></html>', 500], 'HTTP 500'],
|
||||
'non-scalar error fields' => [['{"error":{"code":1}}', 400], 'HTTP 400'],
|
||||
'2xx without JSON object' => [['"just a string"', 200], 'HTTP 200: malformed response'],
|
||||
'tags and control characters' => [["{\"error\":\"<script>x</script>\\nbad\"}", 400], 'HTTP 400: x bad'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('failedResponses')]
|
||||
public function testErrorsAreSanitised(array $response, string $error): void
|
||||
{
|
||||
$this->client->response = $response;
|
||||
|
||||
$this->assertSame($error, $this->requestError('https://login.example.org/token', false));
|
||||
}
|
||||
|
||||
public function testErrorTextIsCutTo200Characters(): void
|
||||
{
|
||||
$this->client->response = [json_encode(['error_description' => str_repeat('x', 500)]), 400];
|
||||
|
||||
$this->assertSame(200, mb_strlen($this->requestError('https://login.example.org/token', false)));
|
||||
}
|
||||
|
||||
private function requestError(string $url, bool $strict): string
|
||||
{
|
||||
try {
|
||||
$this->client->request($url, $strict, []);
|
||||
} catch (OAuthException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
$this->fail('OAuthException expected');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Imapsync\Model;
|
||||
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\CustomParams;
|
||||
use Mailcow\Imapsync\Model\Job;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CustomParamsTest extends TestCase
|
||||
{
|
||||
private const ALLOWLIST = [
|
||||
'debug' => false,
|
||||
'folder' => true,
|
||||
'sslargs1' => '^SSL_verify_mode=[01]$',
|
||||
'tilde' => 'a~b',
|
||||
'broken' => 5,
|
||||
];
|
||||
|
||||
public function testEmptyInputMeansNoOptions(): void
|
||||
{
|
||||
$this->assertSame('[]', CustomParams::toJson(null, self::ALLOWLIST));
|
||||
$this->assertSame('[]', CustomParams::toJson('', self::ALLOWLIST));
|
||||
$this->assertSame('[]', CustomParams::toJson([], self::ALLOWLIST));
|
||||
}
|
||||
|
||||
public function testValidPairsAreNormalisedAndEncoded(): void
|
||||
{
|
||||
$json = CustomParams::toJson([
|
||||
['o' => ' --Debug ', 'v' => ''],
|
||||
['o' => 'folder', 'v' => "INBOX/Sub\0 äö"],
|
||||
['o' => 'sslargs1', 'v' => 'SSL_verify_mode=0'],
|
||||
['o' => '', 'v' => ' '],
|
||||
], self::ALLOWLIST);
|
||||
|
||||
$this->assertSame([
|
||||
['o' => 'debug', 'v' => ''],
|
||||
['o' => 'folder', 'v' => 'INBOX/Sub äö'],
|
||||
['o' => 'sslargs1', 'v' => 'SSL_verify_mode=0'],
|
||||
], json_decode($json, true));
|
||||
$this->assertSame('[{"o":"debug","v":""},{"o":"folder","v":"INBOX/Sub äö"},{"o":"sslargs1","v":"SSL_verify_mode=0"}]', $json);
|
||||
}
|
||||
|
||||
public function testFlagWithWhitespaceValueIsStoredWithoutValue(): void
|
||||
{
|
||||
$this->assertSame('[{"o":"debug","v":""}]', CustomParams::toJson([['o' => 'debug', 'v' => ' ']], self::ALLOWLIST));
|
||||
}
|
||||
|
||||
public static function invalidInputs(): array
|
||||
{
|
||||
return [
|
||||
'string instead of list' => ['--debug', 'imapsync_custom_params_format'],
|
||||
'pair is no array' => [['debug'], 'imapsync_custom_params_format'],
|
||||
'option is array' => [[['o' => ['x'], 'v' => '']], 'imapsync_custom_params_format'],
|
||||
'value is array' => [[['o' => 'folder', 'v' => ['x']]], 'imapsync_custom_params_format'],
|
||||
'unknown option' => [[['o' => '<b>logfile', 'v' => '/etc/passwd']], ['imapsync_custom_param_invalid', '<b>logfile']],
|
||||
'value without option' => [[['o' => '', 'v' => 'x']], ['imapsync_custom_param_invalid', '']],
|
||||
'flag with value' => [[['o' => 'debug', 'v' => '1']], ['imapsync_custom_param_value', 'debug']],
|
||||
'value option without value' => [[['o' => 'folder', 'v' => ' ']], ['imapsync_custom_param_value', 'folder']],
|
||||
'regex mismatch' => [[['o' => 'sslargs1', 'v' => 'SSL_ca_file=/etc/x']], ['imapsync_custom_param_value', 'sslargs1']],
|
||||
'regex must match whole value' => [[['o' => 'sslargs1', 'v' => "SSL_verify_mode=1\n"]], ['imapsync_custom_param_value', 'sslargs1']],
|
||||
'regex empty value' => [[['o' => 'sslargs1', 'v' => '']], ['imapsync_custom_param_value', 'sslargs1']],
|
||||
'malformed rule' => [[['o' => 'broken', 'v' => 'x']], ['imapsync_custom_param_invalid', 'broken']],
|
||||
'value too long' => [[['o' => 'folder', 'v' => str_repeat('a', 1001)]], 'imapsync_custom_params_format'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidInputs')]
|
||||
public function testInvalidInputIsRejected(mixed $input, string|array $msg): void
|
||||
{
|
||||
try {
|
||||
CustomParams::toJson($input, self::ALLOWLIST);
|
||||
$this->fail('UserError expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
|
||||
public function testRegexDelimiterInRuleIsEscaped(): void
|
||||
{
|
||||
$this->assertSame('[{"o":"tilde","v":"a~b"}]', CustomParams::toJson([['o' => 'tilde', 'v' => 'a~b']], self::ALLOWLIST));
|
||||
}
|
||||
|
||||
public function testAtMostOneHundredPairs(): void
|
||||
{
|
||||
$pairs = array_fill(0, 100, ['o' => 'debug', 'v' => '']);
|
||||
$this->assertCount(100, json_decode(CustomParams::toJson($pairs, self::ALLOWLIST), true));
|
||||
|
||||
$this->expectException(UserError::class);
|
||||
$this->expectExceptionMessage('imapsync_custom_params_format');
|
||||
CustomParams::toJson(array_merge($pairs, [['o' => 'debug', 'v' => '']]), self::ALLOWLIST);
|
||||
}
|
||||
|
||||
public static function storedValues(): array
|
||||
{
|
||||
return [
|
||||
'pairs' => ['[{"o":"folder","v":"a\\/b"}]'],
|
||||
'NULL' => [null],
|
||||
'old command line string' => ['--debug --folder INBOX'],
|
||||
'JSON object' => ['{"o":"debug"}'],
|
||||
'list of non-pairs' => ['[1,"x",{"v":"no option"}]'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('storedValues')]
|
||||
public function testStoredValuesPassThroughUnchanged(?string $stored): void
|
||||
{
|
||||
$this->assertSame($stored, Job::fromRow(['id' => 1, 'custom_params' => $stored])->toArray()['custom_params']);
|
||||
}
|
||||
|
||||
public function testAllowlistDropsMalformedEntries(): void
|
||||
{
|
||||
$allowlist = CustomParams::allowlist([
|
||||
'--Folder' => true,
|
||||
'debug' => false,
|
||||
'sslargs1' => '^x$',
|
||||
'1abc' => true,
|
||||
'a-b' => true,
|
||||
'empty' => '',
|
||||
'number' => 1,
|
||||
], ['default' => true]);
|
||||
|
||||
$this->assertSame(['folder' => true, 'debug' => false, 'sslargs1' => '^x$'], $allowlist);
|
||||
}
|
||||
|
||||
public function testAllowlistFallsBackToDefaults(): void
|
||||
{
|
||||
$this->assertSame(['default' => true], CustomParams::allowlist([], ['default' => true]));
|
||||
$this->assertSame(['default' => true], CustomParams::allowlist(['bad name' => true], ['default' => true]));
|
||||
}
|
||||
|
||||
public function testOldWhitelistFormatIsIgnored(): void
|
||||
{
|
||||
$errorLog = ini_set('error_log', '/dev/null');
|
||||
try {
|
||||
$allowlist = CustomParams::allowlist(['whitelist' => ['debug'], 'folder' => true], ['default' => true]);
|
||||
} finally {
|
||||
ini_set('error_log', (string)$errorLog);
|
||||
}
|
||||
|
||||
$this->assertSame(['folder' => true], $allowlist);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Imapsync\Service;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\Job;
|
||||
use Mailcow\Imapsync\Model\Settings;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\JobRepository;
|
||||
use Mailcow\Imapsync\Repository\SettingsRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
use Mailcow\Imapsync\Service\JobService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class JobServiceTest extends TestCase
|
||||
{
|
||||
private const ALLOWLIST = ['debug' => false, 'folder' => true];
|
||||
|
||||
private JobRepository&MockObject $jobs;
|
||||
private SourceService&MockObject $sources;
|
||||
private TokenRepository&MockObject $tokens;
|
||||
private SettingsRepository&MockObject $settings;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->jobs = $this->createMock(JobRepository::class);
|
||||
$this->sources = $this->createMock(SourceService::class);
|
||||
$this->tokens = $this->createMock(TokenRepository::class);
|
||||
$this->settings = $this->createMock(SettingsRepository::class);
|
||||
}
|
||||
|
||||
private function service(Context $context): JobService
|
||||
{
|
||||
return new JobService($this->jobs, $this->sources, $this->tokens, $this->settings, $context, self::ALLOWLIST);
|
||||
}
|
||||
|
||||
private static function source(array $row = []): Source
|
||||
{
|
||||
return Source::fromRow($row + ['id' => 5, 'name' => 'src', 'created_by' => 'admin', 'host1' => 'imap.example.org',
|
||||
'port1' => 993, 'enc1' => 'SSL', 'auth_type' => 'PLAIN', 'active' => 1]);
|
||||
}
|
||||
|
||||
private static function storedJob(array $row = [], array $source = []): Job
|
||||
{
|
||||
$source += ['source_name' => 'src', 'source_auth_type' => 'PLAIN', 'source_oauth_flow' => 'client_credentials',
|
||||
'source_created_by' => 'admin', 'source_active' => 1];
|
||||
return Job::fromRow($row + $source + [
|
||||
'id' => 9, 'user2' => 'local@example.org', 'user1' => 'remote@example.net', 'password1' => 'stored-pw',
|
||||
'source_id' => 5, 'mins_interval' => 20, 'maxage' => 0, 'timeout1' => 600, 'timeout2' => 600,
|
||||
'maxbytespersecond' => '0', 'exclude' => '', 'subfolder2' => '', 'custom_params' => '[{"o":"debug","v":""}]',
|
||||
'last_run' => '2026-10-01 10:00:00', 'success' => 1, 'prio' => 3, 'active' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function jobInput(array $override = []): array
|
||||
{
|
||||
return $override + ['username' => 'local@example.org', 'user1' => 'remote@example.net', 'password1' => 'pw',
|
||||
'source_id' => 5, 'mins_interval' => 20];
|
||||
}
|
||||
|
||||
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 function testCreateNeedsAcl(): void
|
||||
{
|
||||
$this->jobs->expects($this->never())->method('insert');
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('u@example.org', []))->create(self::jobInput()));
|
||||
}
|
||||
|
||||
public function testOwnerMailboxRules(): void
|
||||
{
|
||||
$this->expectUserError('no_user_defined', fn() => $this->service(FakeContext::domainAdmin())->create(self::jobInput(['username' => null])));
|
||||
$this->expectUserError('no_user_defined', fn() => $this->service(FakeContext::admin())->create(self::jobInput(['username' => 'not-a-mailbox'])));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('u@example.org'))->create(self::jobInput()));
|
||||
}
|
||||
|
||||
public function testCreateStoresJobWithDefaults(): void
|
||||
{
|
||||
$this->sources->method('get')->with(5)->willReturn(self::source());
|
||||
$this->jobs->expects($this->once())->method('insert')->with($this->callback(function (Job $job): bool {
|
||||
return $job->user2 === 'u@example.org' && $job->user1 === 'remote@example.net' && $job->password1 === 'pw'
|
||||
&& $job->maxage === 0 && $job->timeout1 === 600 && $job->timeout2 === 600 && $job->maxBytesPerSecond === '0'
|
||||
&& $job->minsInterval === 20 && $job->active === 0 && $job->customParams === '[{"o":"folder","v":"INBOX"}]';
|
||||
}));
|
||||
|
||||
$job = $this->service(FakeContext::user('u@example.org'))->create(self::jobInput([
|
||||
'username' => null, 'user1' => ' remote@example.net ', 'maxage' => 'abc', 'timeout1' => 0, 'maxbytespersecond' => 200000000,
|
||||
'custom_params' => [['o' => 'folder', 'v' => 'INBOX']],
|
||||
]));
|
||||
|
||||
$this->assertSame('u@example.org', $job->user2);
|
||||
}
|
||||
|
||||
public function testCreateKeepsValidNumbers(): void
|
||||
{
|
||||
$this->sources->method('get')->willReturn(self::source());
|
||||
$this->jobs->expects($this->once())->method('insert')->with($this->callback(
|
||||
fn(Job $job): bool => $job->maxage === 30 && $job->timeout1 === 120 && $job->maxBytesPerSecond === '5000' && $job->minsInterval === 43800
|
||||
));
|
||||
|
||||
$this->service(FakeContext::admin())->create(self::jobInput(['maxage' => '30', 'timeout1' => '120', 'maxbytespersecond' => '5000', 'mins_interval' => 43800]));
|
||||
}
|
||||
|
||||
public static function invalidCreates(): array
|
||||
{
|
||||
return [
|
||||
'user1 missing' => [['user1' => ''], 'imapsync_user1_invalid'],
|
||||
'user1 array' => [['user1' => ['x']], 'imapsync_user1_invalid'],
|
||||
'user1 too long' => [['user1' => str_repeat('u', 256)], 'imapsync_user1_invalid'],
|
||||
'password missing' => [['password1' => ''], 'password_empty'],
|
||||
'password array' => [['password1' => ['x']], 'password_empty'],
|
||||
'custom params string' => [['custom_params' => '--debug'], 'imapsync_custom_params_format'],
|
||||
'custom param unknown' => [['custom_params' => [['o' => 'logfile', 'v' => 'x']]], ['imapsync_custom_param_invalid', 'logfile']],
|
||||
'exclude array' => [['exclude' => ['x']], 'access_denied'],
|
||||
'interval 0' => [['mins_interval' => 0], 'access_denied'],
|
||||
'interval too large' => [['mins_interval' => 43801], 'access_denied'],
|
||||
'exclude no regex' => [['exclude' => '(unclosed'], 'access_denied'],
|
||||
'user1 checked before password' => [['user1' => '', 'password1' => ''], 'imapsync_user1_invalid'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidCreates')]
|
||||
public function testCreateValidation(array $override, string|array $msg): void
|
||||
{
|
||||
$this->sources->method('get')->willReturn(self::source());
|
||||
$this->jobs->expects($this->never())->method('insert');
|
||||
|
||||
$this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->create(self::jobInput($override)));
|
||||
}
|
||||
|
||||
public function testCreateNeedsVisibleActiveSource(): void
|
||||
{
|
||||
$this->sources->method('get')->willReturnOnConsecutiveCalls(null, self::source(['active' => 0]));
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$this->expectUserError('imapsync_source_unavailable', fn() => $service->create(self::jobInput()));
|
||||
$this->expectUserError('imapsync_source_unavailable', fn() => $service->create(self::jobInput()));
|
||||
}
|
||||
|
||||
public function testDuplicateTupleIsRejected(): void
|
||||
{
|
||||
$this->sources->method('get')->willReturn(self::source(['name' => '<src>']));
|
||||
$this->jobs->method('tupleExists')->with(5, 'local@example.org', 'remote@example.net', null)->willReturn(true);
|
||||
|
||||
$this->expectUserError(['object_exists', '<src> / remote@example.net'], fn() => $this->service(FakeContext::admin())->create(self::jobInput()));
|
||||
}
|
||||
|
||||
public function testAuthorizationCodeJobNeedsTokenOfOwnerAndRemoteUser(): void
|
||||
{
|
||||
$this->sources->method('get')->willReturn(self::source(['auth_type' => 'XOAUTH2', 'oauth_flow' => 'authorization_code']));
|
||||
$this->tokens->method('exists')->with(5, 'local@example.org', 'remote@example.net')->willReturnOnConsecutiveCalls(false, true);
|
||||
$this->jobs->expects($this->once())->method('insert')->with($this->callback(fn(Job $job): bool => $job->password1 === ''));
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$this->expectUserError('imapsync_source_oauth_connect_required', fn() => $service->create(self::jobInput()));
|
||||
$service->create(self::jobInput());
|
||||
}
|
||||
|
||||
public function testClientCredentialsJobStoresNoPassword(): void
|
||||
{
|
||||
$this->sources->method('get')->willReturn(self::source(['auth_type' => 'XOAUTH2', 'oauth_flow' => 'client_credentials']));
|
||||
$this->tokens->expects($this->never())->method('exists');
|
||||
$this->jobs->expects($this->once())->method('insert')->with($this->callback(fn(Job $job): bool => $job->password1 === ''));
|
||||
|
||||
$this->service(FakeContext::admin())->create(self::jobInput(['password1' => '']));
|
||||
}
|
||||
|
||||
public function testUpdateNeedsAccessToTheJob(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturnOnConsecutiveCalls(null, self::storedJob());
|
||||
$this->jobs->expects($this->never())->method('update');
|
||||
$service = $this->service(FakeContext::user('u@example.org'));
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $service->update(9, []));
|
||||
$this->expectUserError('access_denied', fn() => $service->update(9, []));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('local@example.org', []))->update(9, []));
|
||||
}
|
||||
|
||||
public function testEmptyPasswordKeepsTheStoredOne(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob());
|
||||
$stored = [];
|
||||
$this->jobs->method('update')->willReturnCallback(function (Job $job) use (&$stored): void {
|
||||
$stored[] = $job->password1;
|
||||
});
|
||||
$service = $this->service(FakeContext::user('local@example.org'));
|
||||
|
||||
$service->update(9, ['password1' => '']);
|
||||
$service->update(9, ['password1' => ['x']]);
|
||||
$service->update(9, ['password1' => 'new']);
|
||||
|
||||
$this->assertSame(['stored-pw', 'stored-pw', 'new'], $stored);
|
||||
}
|
||||
|
||||
public function testMissingAttributesKeepTheirValue(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob());
|
||||
$this->jobs->expects($this->once())->method('update')->with($this->callback(function (Job $job): bool {
|
||||
return $job->active === 0 && $job->user1 === 'remote@example.net' && $job->minsInterval === 20 && $job->prio === 3
|
||||
&& $job->customParams === '[{"o":"debug","v":""}]' && $job->lastRun === null && $job->success === 1
|
||||
&& $job->maxage === 7 && $job->timeout1 === 600;
|
||||
}));
|
||||
$this->sources->expects($this->never())->method('get');
|
||||
$this->tokens->expects($this->never())->method('deleteIfUnused');
|
||||
|
||||
$this->service(FakeContext::user('local@example.org'))->update(9, [
|
||||
'active' => '0', 'user1' => '', 'mins_interval' => '', 'prio' => 99, 'last_run' => '', 'maxage' => '7', 'timeout1' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testOnlyAdminsChangePrio(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob());
|
||||
$this->jobs->expects($this->once())->method('update')->with($this->callback(fn(Job $job): bool => $job->prio === 99));
|
||||
|
||||
$this->service(FakeContext::admin())->update(9, ['prio' => '99']);
|
||||
}
|
||||
|
||||
public function testJobOnInvisibleSourceStaysEditable(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob([], ['source_created_by' => 'someone-else', 'source_active' => 0]));
|
||||
$this->sources->expects($this->never())->method('get');
|
||||
$this->jobs->expects($this->once())->method('update');
|
||||
|
||||
$this->service(FakeContext::user('local@example.org'))->update(9, ['active' => 0]);
|
||||
}
|
||||
|
||||
public function testMovingToAnotherSourceNeedsVisibleActiveSource(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob());
|
||||
$this->sources->method('get')->with(6)->willReturnOnConsecutiveCalls(null, self::source(['id' => 6, 'active' => 0]));
|
||||
$service = $this->service(FakeContext::user('local@example.org'));
|
||||
|
||||
$this->expectUserError('imapsync_source_unavailable', fn() => $service->update(9, ['source_id' => 6]));
|
||||
$this->expectUserError('imapsync_source_unavailable', fn() => $service->update(9, ['source_id' => 6]));
|
||||
}
|
||||
|
||||
public function testMovingToXOAuth2SourceWipesPasswordAndChecksToken(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob());
|
||||
$this->sources->method('get')->willReturn(self::source(['id' => 6, 'auth_type' => 'XOAUTH2', 'oauth_flow' => 'authorization_code']));
|
||||
$this->tokens->method('exists')->with(6, 'local@example.org', 'remote@example.net')->willReturnOnConsecutiveCalls(false, true);
|
||||
$this->jobs->expects($this->once())->method('update')->with($this->callback(fn(Job $job): bool => $job->password1 === '' && $job->sourceId === 6));
|
||||
$this->tokens->expects($this->once())->method('deleteIfUnused')->with(5, 'local@example.org', 'remote@example.net');
|
||||
$service = $this->service(FakeContext::user('local@example.org'));
|
||||
|
||||
$this->expectUserError('imapsync_source_oauth_connect_required', fn() => $service->update(9, ['source_id' => 6]));
|
||||
$service->update(9, ['source_id' => 6]);
|
||||
}
|
||||
|
||||
public function testSwitchingFromXOAuth2NeedsPassword(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob(['password1' => '']));
|
||||
$this->sources->method('get')->willReturn(self::source(['id' => 6]));
|
||||
|
||||
$this->expectUserError('password_empty', fn() => $this->service(FakeContext::user('local@example.org'))->update(9, ['source_id' => 6]));
|
||||
}
|
||||
|
||||
public function testRemoteUserOfClientCredentialsSourceNeedsSourceAccess(): void
|
||||
{
|
||||
$job = self::storedJob(['password1' => ''], ['source_auth_type' => 'XOAUTH2', 'source_oauth_flow' => 'client_credentials',
|
||||
'source_created_by' => 'admin']);
|
||||
$this->jobs->method('find')->willReturn($job);
|
||||
$this->sources->method('get')->with(5)->willReturnOnConsecutiveCalls(null, self::source());
|
||||
$this->sources->method('canEdit')->willReturn(false);
|
||||
$this->jobs->expects($this->once())->method('update');
|
||||
$service = $this->service(FakeContext::user('local@example.org'));
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $service->update(9, ['user1' => 'ceo@example.net']));
|
||||
$service->update(9, ['user1' => 'ceo@example.net']);
|
||||
}
|
||||
|
||||
public function testChangedTupleMustStayUnique(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob());
|
||||
$this->jobs->method('tupleExists')->with(5, 'local@example.org', 'other@example.net', 9)->willReturn(true);
|
||||
|
||||
$this->expectUserError(['object_exists', 'src / other@example.net'],
|
||||
fn() => $this->service(FakeContext::user('local@example.org'))->update(9, ['user1' => 'other@example.net']));
|
||||
}
|
||||
|
||||
public function testDeleteDropsUnusedToken(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturnOnConsecutiveCalls(self::storedJob(['user2' => 'x@example.org']), self::storedJob());
|
||||
$this->jobs->expects($this->once())->method('delete')->with(9);
|
||||
$this->tokens->expects($this->once())->method('deleteIfUnused')->with(5, 'local@example.org', 'remote@example.net');
|
||||
$service = $this->service(FakeContext::user('local@example.org'));
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $service->delete(9));
|
||||
$service->delete(9);
|
||||
}
|
||||
|
||||
public function testAdminsManageOrphanedJobs(): void
|
||||
{
|
||||
$this->jobs->method('find')->willReturn(self::storedJob(['user2' => 'deleted@example.org']));
|
||||
|
||||
$this->assertNotNull($this->service(new FakeContext('admin', 'admin'))->get(9));
|
||||
$this->assertNull($this->service(FakeContext::domainAdmin())->get(9));
|
||||
}
|
||||
|
||||
public function testJobListsFollowMailboxAccess(): void
|
||||
{
|
||||
$this->jobs->method('idsForMailbox')->willReturnMap([['u@example.org', [1, 2]], ['m@example.org', [3]]]);
|
||||
$this->jobs->method('allIds')->willReturn([3, 1, 2]);
|
||||
$user = $this->service(FakeContext::user('u@example.org'));
|
||||
|
||||
$this->assertSame([1, 2], $user->idsForMailbox(null));
|
||||
$this->assertSame([1, 2], $user->idsForMailbox('not-an-address'));
|
||||
$this->assertSame([], $user->idsForMailbox('m@example.org'));
|
||||
$this->assertSame([3], $this->service(FakeContext::domainAdmin(['m@example.org']))->idsForMailbox('m@example.org'));
|
||||
$this->assertSame([], $this->service(new FakeContext('', null))->idsForMailbox(null));
|
||||
$this->assertSame([], $user->allIds());
|
||||
$this->assertSame([3, 1, 2], $this->service(FakeContext::admin())->allIds());
|
||||
}
|
||||
|
||||
public function testRunNextIsAdminOnly(): void
|
||||
{
|
||||
$this->jobs->expects($this->once())->method('moveToFront')->with([4, 2])->willReturn(['running' => [], 'moved' => 2]);
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin())->runNext([4]));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::admin())->runNext([]));
|
||||
$this->assertSame(['running' => [], 'moved' => 2], $this->service(FakeContext::admin())->runNext([4, 2, 4]));
|
||||
}
|
||||
|
||||
public static function invalidSettings(): array
|
||||
{
|
||||
return [
|
||||
'parallel 0' => [['max_parallel' => 0], 'imapsync_max_parallel_invalid'],
|
||||
'parallel 33' => [['max_parallel' => 33], 'imapsync_max_parallel_invalid'],
|
||||
'parallel text' => [['max_parallel' => 'many', 'max_kb_per_second' => -1], 'imapsync_max_parallel_invalid'],
|
||||
'kb negative' => [['max_parallel' => 2, 'max_kb_per_second' => -1], 'imapsync_max_kb_per_second_invalid'],
|
||||
'kb too large' => [['max_parallel' => 2, 'max_kb_per_second' => 10485761], 'imapsync_max_kb_per_second_invalid'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidSettings')]
|
||||
public function testSettingsValidation(array $input, string $msg): void
|
||||
{
|
||||
$this->settings->expects($this->never())->method('save');
|
||||
|
||||
$this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->saveSettings($input));
|
||||
}
|
||||
|
||||
public function testSettingsAreStoredInBytes(): void
|
||||
{
|
||||
$this->settings->expects($this->exactly(2))->method('save')->with($this->callback(
|
||||
fn(Settings $s): bool => $s->maxParallel === 4 && in_array($s->maxBytesPerSecond, [0, 2048], true)
|
||||
));
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$this->assertSame(2048, $service->saveSettings(['max_parallel' => '4', 'max_kb_per_second' => '2'])->maxBytesPerSecond);
|
||||
$this->assertSame(0, $service->saveSettings(['max_parallel' => 4, 'max_kb_per_second' => ''])->maxBytesPerSecond);
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin())->saveSettings(['max_parallel' => 1]));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin())->getSettings());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Imapsync\Service;
|
||||
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Http\OAuthClient;
|
||||
use Mailcow\Imapsync\Http\OAuthException;
|
||||
use Mailcow\Imapsync\Model\OAuthToken;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\SourceRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
use Mailcow\Imapsync\Service\OAuthService;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class OAuthServiceTest extends TestCase
|
||||
{
|
||||
private SourceRepository&MockObject $sources;
|
||||
private TokenRepository&MockObject $tokens;
|
||||
private OAuthClient&MockObject $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->sources = $this->createMock(SourceRepository::class);
|
||||
$this->tokens = $this->createMock(TokenRepository::class);
|
||||
$this->client = $this->createMock(OAuthClient::class);
|
||||
}
|
||||
|
||||
private function service(string $hostname = 'Mail.Example.org '): OAuthService
|
||||
{
|
||||
return new OAuthService($this->sources, $this->tokens, $this->client, $hostname);
|
||||
}
|
||||
|
||||
private static function source(array $row = []): Source
|
||||
{
|
||||
return Source::fromRow($row + [
|
||||
'id' => 4, 'name' => 'm365', 'created_by' => 'da', 'host1' => 'outlook.office365.com', 'port1' => 993,
|
||||
'auth_type' => 'XOAUTH2', 'oauth_flow' => 'client_credentials', 'active' => 1,
|
||||
'oauth_token_endpoint' => 'https://login.example.org/token', 'oauth_authorize_endpoint' => 'https://login.example.org/authorize?tenant=x',
|
||||
'oauth_userinfo_endpoint' => 'https://login.example.org/userinfo',
|
||||
'oauth_client_id' => 'client', 'oauth_client_secret' => 'secret', 'oauth_scope' => 'scope',
|
||||
]);
|
||||
}
|
||||
|
||||
private static function userToken(array $row = []): OAuthToken
|
||||
{
|
||||
return OAuthToken::fromRow($row + ['source_id' => 4, 'owner' => 'local@example.org', 'username' => 'remote@example.net',
|
||||
'refresh_token' => 'old-refresh', 'access_token' => 'old-access']);
|
||||
}
|
||||
|
||||
public function testRedirectUriUsesMailcowHostnameOnly(): void
|
||||
{
|
||||
$this->assertSame('https://mail.example.org/syncjob-oauth', $this->service()->redirectUri());
|
||||
$this->assertSame('', $this->service('')->redirectUri());
|
||||
}
|
||||
|
||||
public function testEndpointsOfAdminSourcesAreNotAddressRestricted(): void
|
||||
{
|
||||
$this->sources->method('isSuperAdmin')->willReturnMap([['admin', true], ['da', false]]);
|
||||
$this->client->expects($this->exactly(3))->method('isAllowed')
|
||||
->willReturnCallback(fn(string $url, bool $strict): bool => !$strict);
|
||||
$service = $this->service();
|
||||
|
||||
foreach (['admin' => true, 'API' => true, 'da' => false] as $createdBy => $allowed) {
|
||||
$source = self::source(['created_by' => $createdBy, 'oauth_flow' => 'authorization_code']);
|
||||
$this->assertSame($allowed, $service->authorizeUrl($source, 'state') !== null, $createdBy);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAuthorizeUrl(): void
|
||||
{
|
||||
$this->client->method('isAllowed')->willReturnOnConsecutiveCalls(true, false);
|
||||
$source = self::source(['oauth_flow' => 'authorization_code']);
|
||||
|
||||
$this->assertSame('https://login.example.org/authorize?tenant=x&client_id=client&response_type=code'
|
||||
. '&redirect_uri=https%3A%2F%2Fmail.example.org%2Fsyncjob-oauth&response_mode=query&scope=scope&state=abc',
|
||||
$this->service()->authorizeUrl($source, 'abc'));
|
||||
$this->assertNull($this->service()->authorizeUrl($source, 'abc'));
|
||||
}
|
||||
|
||||
public function testClientCredentialsRefreshStoresToken(): void
|
||||
{
|
||||
$this->client->expects($this->once())->method('request')->with(
|
||||
'https://login.example.org/token',
|
||||
true,
|
||||
['resource' => 'r', 'client_id' => 'client', 'client_secret' => 'secret', 'scope' => 'scope', 'grant_type' => 'client_credentials']
|
||||
)->willReturn(['access_token' => 'new', 'expires_in' => '120']);
|
||||
$this->tokens->expects($this->once())->method('storeSourceToken')->with(4, 'new', $this->callback(
|
||||
fn(int $expires): bool => abs($expires - (time() + 120)) <= 2
|
||||
));
|
||||
|
||||
// Stored extra params never override the client and grant fields
|
||||
$source = self::source(['oauth_extra_params' => '{"resource":"r","client_id":"evil","grant_type":"password"}']);
|
||||
$this->assertNull($this->service()->refreshSourceToken($source));
|
||||
}
|
||||
|
||||
public function testFailedRefreshStoresTheSanitisedError(): void
|
||||
{
|
||||
$this->client->method('request')->willThrowException(new OAuthException('HTTP 401: invalid_client'));
|
||||
$this->tokens->expects($this->once())->method('storeSourceError')->with(4, 'HTTP 401: invalid_client');
|
||||
$this->tokens->expects($this->never())->method('storeSourceToken');
|
||||
|
||||
$this->assertSame('HTTP 401: invalid_client', $this->service()->refreshSourceToken(self::source()));
|
||||
}
|
||||
|
||||
public function testTokenResponseWithoutAccessTokenIsMalformed(): void
|
||||
{
|
||||
$this->client->method('request')->willReturn(['access_token' => ['x']]);
|
||||
$this->tokens->expects($this->once())->method('storeSourceError')->with(4, 'malformed token response');
|
||||
|
||||
$this->assertSame('malformed token response', $this->service()->refreshSourceToken(self::source()));
|
||||
}
|
||||
|
||||
public function testOnlyClientCredentialsSourcesHaveASourceToken(): void
|
||||
{
|
||||
$this->client->expects($this->never())->method('request');
|
||||
|
||||
$this->assertSame('not refreshable', $this->service()->refreshSourceToken(self::source(['oauth_flow' => 'authorization_code'])));
|
||||
}
|
||||
|
||||
public function testUserRefreshKeepsRefreshTokenWhenProviderSendsNone(): void
|
||||
{
|
||||
$source = self::source(['oauth_flow' => 'authorization_code']);
|
||||
$this->client->method('request')->willReturnOnConsecutiveCalls(
|
||||
['access_token' => 'a1'],
|
||||
['access_token' => 'a2', 'refresh_token' => 'r2', 'expires_in' => 60]
|
||||
);
|
||||
$stored = [];
|
||||
$this->tokens->method('storeUserRefresh')->willReturnCallback(function (OAuthToken $t) use (&$stored): void {
|
||||
$stored[] = [$t->accessToken, $t->refreshToken, $t->tokenExpires - time()];
|
||||
});
|
||||
|
||||
$this->assertNull($this->service()->refreshUserToken($source, self::userToken()));
|
||||
$this->assertNull($this->service()->refreshUserToken($source, self::userToken()));
|
||||
|
||||
$this->assertEqualsWithDelta([['a1', 'old-refresh', 3600], ['a2', 'r2', 60]], $stored, 2);
|
||||
}
|
||||
|
||||
public function testUserRefreshFailureIsStoredOnTheToken(): void
|
||||
{
|
||||
$this->client->method('request')->willThrowException(new OAuthException('HTTP 400: invalid_grant'));
|
||||
$this->tokens->expects($this->once())->method('storeUserError')->with($this->isInstanceOf(OAuthToken::class), 'HTTP 400: invalid_grant');
|
||||
|
||||
$this->assertSame('HTTP 400: invalid_grant',
|
||||
$this->service()->refreshUserToken(self::source(['oauth_flow' => 'authorization_code']), self::userToken()));
|
||||
$this->assertSame('not refreshable',
|
||||
$this->service()->refreshUserToken(self::source(['oauth_flow' => 'authorization_code']), self::userToken(['refresh_token' => ''])));
|
||||
}
|
||||
|
||||
public function testRefreshDueAppliesExpiryAndRetryRules(): void
|
||||
{
|
||||
$soon = time() + 60;
|
||||
$this->tokens->method('refreshableSources')->willReturn([
|
||||
self::source(['id' => 1, 'name' => 'no token']),
|
||||
self::source(['id' => 2, 'name' => 'valid', 'oauth_token_expires' => time() + 3600]),
|
||||
self::source(['id' => 3, 'name' => 'failed recently', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 900]),
|
||||
self::source(['id' => 4, 'name' => 'failed long ago', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 901]),
|
||||
]);
|
||||
$this->tokens->method('deleteUnusedOlderThan')->with(86400)->willReturn(0);
|
||||
$this->tokens->method('refreshableUserTokens')->willReturn([]);
|
||||
$this->client->expects($this->exactly(2))->method('request')->willReturnOnConsecutiveCalls(
|
||||
['access_token' => 't'],
|
||||
$this->throwException(new OAuthException('HTTP 401: invalid_client'))
|
||||
);
|
||||
|
||||
$this->assertSame([
|
||||
['priority' => 'info', 'message' => "Refreshed token for source 'no token' (id=1)"],
|
||||
['priority' => 'warning', 'message' => "Refresh failed for source 'failed long ago' (id=4): HTTP 401: invalid_client"],
|
||||
], $this->service()->refreshDue());
|
||||
}
|
||||
|
||||
public function testRefreshDueCleansUpAndRefreshesUserTokens(): void
|
||||
{
|
||||
$source = self::source(['oauth_flow' => 'authorization_code']);
|
||||
$this->tokens->method('refreshableSources')->willReturn([]);
|
||||
$this->tokens->method('deleteUnusedOlderThan')->willReturn(2);
|
||||
$this->tokens->method('refreshableUserTokens')->willReturn([
|
||||
self::userToken(),
|
||||
self::userToken(['username' => 'fresh@example.net', 'token_expires' => time() + 3600]),
|
||||
self::userToken(['username' => 'db@example.net']),
|
||||
self::userToken(['source_id' => 99, 'username' => 'orphan@example.net']),
|
||||
]);
|
||||
$this->sources->method('find')->willReturnMap([[4, $source], [99, null]]);
|
||||
$this->client->method('request')->willReturn(['access_token' => 'new']);
|
||||
$this->tokens->method('storeUserRefresh')->willReturnOnConsecutiveCalls(null, $this->throwException(new PDOException('lost')));
|
||||
|
||||
$this->assertSame([
|
||||
['priority' => 'info', 'message' => 'Removed 2 unused user token(s)'],
|
||||
['priority' => 'info', 'message' => "Refreshed user token 'remote@example.net' (mailbox local@example.org) on source 'm365' (id=4)"],
|
||||
['priority' => 'warning', 'message' => "Refresh failed for user token 'db@example.net' (mailbox local@example.org) on source 'm365' (id=4): PDOException"],
|
||||
['priority' => 'warning', 'message' => "Refresh failed for user token 'orphan@example.net' (mailbox local@example.org) on source '' (id=99): not refreshable"],
|
||||
], $this->service()->refreshDue());
|
||||
}
|
||||
|
||||
public function testConnectUserTakesIdentityFromIdToken(): void
|
||||
{
|
||||
$claims = rtrim(strtr(base64_encode(json_encode(['preferred_username' => 'not an address', 'upn' => 'Remote@Example.NET'])), '+/', '-_'), '=');
|
||||
$this->client->expects($this->once())->method('request')->with(
|
||||
'https://login.example.org/token',
|
||||
true,
|
||||
$this->callback(fn(array $post): bool => $post['grant_type'] === 'authorization_code' && $post['code'] === 'c0de'
|
||||
&& $post['redirect_uri'] === 'https://mail.example.org/syncjob-oauth')
|
||||
)->willReturn(['access_token' => 'acc', 'expires_in' => 100, 'id_token' => 'h.' . $claims . '.s']);
|
||||
$this->tokens->expects($this->once())->method('saveUserToken')->with($this->callback(
|
||||
fn(OAuthToken $t): bool => $t->sourceId === 4 && $t->owner === 'local@example.org' && $t->username === 'remote@example.net'
|
||||
&& $t->accessToken === 'acc' && $t->refreshToken === ''
|
||||
));
|
||||
|
||||
$this->assertSame('remote@example.net', $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code']), 'local@example.org', 'c0de'));
|
||||
}
|
||||
|
||||
public function testConnectUserFallsBackToUserinfo(): void
|
||||
{
|
||||
$this->client->method('request')->willReturnOnConsecutiveCalls(
|
||||
['access_token' => 'acc', 'refresh_token' => 'ref'],
|
||||
['email' => 'remote@example.net']
|
||||
);
|
||||
$this->tokens->expects($this->once())->method('saveUserToken')->with($this->callback(fn(OAuthToken $t): bool => $t->refreshToken === 'ref'));
|
||||
|
||||
$this->assertSame('remote@example.net', $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code']), 'local@example.org', 'c'));
|
||||
}
|
||||
|
||||
public function testConnectUserErrors(): void
|
||||
{
|
||||
$source = self::source(['oauth_flow' => 'authorization_code']);
|
||||
$this->client->method('request')->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new OAuthException('HTTP 400: invalid_grant')),
|
||||
['access_token' => 'acc'],
|
||||
$this->throwException(new OAuthException('HTTP 401')),
|
||||
['access_token' => 'acc', 'id_token' => 'not.a.jwt.at.all']
|
||||
);
|
||||
$this->tokens->expects($this->never())->method('saveUserToken');
|
||||
|
||||
$this->assertUserError(['token_failed', 'token exchange failed for source 4: HTTP 400: invalid_grant'], fn() => $this->service()->connectUser($source, 'o', 'c'));
|
||||
$this->assertUserError('no_identity', fn() => $this->service()->connectUser($source, 'o', 'c'));
|
||||
$this->assertUserError('no_identity', fn() => $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code', 'oauth_userinfo_endpoint' => null]), 'o', 'c'));
|
||||
}
|
||||
|
||||
public function testConnectUserReportsStorageFailure(): void
|
||||
{
|
||||
$this->client->method('request')->willReturnOnConsecutiveCalls(['access_token' => 'acc'], ['email' => 'r@example.net']);
|
||||
$this->tokens->method('saveUserToken')->willThrowException(new PDOException('duplicate'));
|
||||
|
||||
$this->assertUserError(['token_failed', 'storing token failed for source 4: duplicate'],
|
||||
fn() => $this->service()->connectUser(self::source(['oauth_flow' => 'authorization_code']), 'o', 'c'));
|
||||
}
|
||||
|
||||
private function assertUserError(string|array $msg, callable $call): void
|
||||
{
|
||||
try {
|
||||
$call();
|
||||
$this->fail('UserError expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Imapsync\Service;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Http\OAuthClient;
|
||||
use Mailcow\Imapsync\Http\OAuthException;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\SourceRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
use Mailcow\Imapsync\Service\OAuthService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SourceServiceTest extends TestCase
|
||||
{
|
||||
private SourceRepository&MockObject $repo;
|
||||
private TokenRepository&MockObject $tokens;
|
||||
private OAuthClient&MockObject $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(SourceRepository::class);
|
||||
$this->tokens = $this->createMock(TokenRepository::class);
|
||||
$this->client = $this->createMock(OAuthClient::class);
|
||||
$this->client->method('isAllowed')->willReturn(true);
|
||||
}
|
||||
|
||||
/** SourceService with the real OAuthService (settings rules); only the HTTP client and the repositories are mocked */
|
||||
private function service(Context $context, ?OAuthClient $client = null): SourceService
|
||||
{
|
||||
return new SourceService($this->repo, $this->tokens,
|
||||
new OAuthService($this->repo, $this->tokens, $client ?? $this->client, 'mail.example.org'), $context);
|
||||
}
|
||||
|
||||
private static function source(array $row = []): Source
|
||||
{
|
||||
return Source::fromRow($row + [
|
||||
'id' => 7,
|
||||
'name' => 'src',
|
||||
'created_by' => 'owner@example.org',
|
||||
'scope' => 'user',
|
||||
'host1' => 'imap.example.org',
|
||||
'port1' => 993,
|
||||
'enc1' => 'SSL',
|
||||
'auth_type' => 'PLAIN',
|
||||
'active' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function oauthSource(array $row = []): Source
|
||||
{
|
||||
return self::source($row + [
|
||||
'created_by' => 'da',
|
||||
'auth_type' => 'XOAUTH2',
|
||||
'oauth_flow' => 'client_credentials',
|
||||
'oauth_token_endpoint' => 'https://login.example.org/token',
|
||||
'oauth_client_id' => 'client',
|
||||
'oauth_client_secret' => 'stored-secret',
|
||||
'oauth_scope' => 'https://outlook.office365.com/.default',
|
||||
'oauth_access_token' => 'access',
|
||||
]);
|
||||
}
|
||||
|
||||
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 editMatrix(): array
|
||||
{
|
||||
$other = ['other@example.org'];
|
||||
return [
|
||||
'admin edits everything' => [FakeContext::admin(), 'x@example.org', true],
|
||||
'admin edits handed-over sources' => [FakeContext::admin(), '', true],
|
||||
'access_all_exception' => [new FakeContext('user', 'u@example.org', [], true), 'x@example.org', true],
|
||||
'user edits own' => [FakeContext::user('u@example.org'), 'u@example.org', true],
|
||||
'user not foreign' => [FakeContext::user('u@example.org'), 'x@example.org', false],
|
||||
'user not handed-over' => [FakeContext::user('u@example.org'), '', false],
|
||||
'domain admin edits own' => [FakeContext::domainAdmin(), 'da', true],
|
||||
'domain admin edits his mailboxes' => [FakeContext::domainAdmin($other), 'other@example.org', true],
|
||||
'domain admin not foreign mailboxes' => [FakeContext::domainAdmin($other), 'x@example.org', false],
|
||||
'domain admin not handed-over' => [FakeContext::domainAdmin(['']), '', false],
|
||||
'missing username is no creator' => [new FakeContext('user', null), '', false],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('editMatrix')]
|
||||
public function testEditPermissionMatrix(Context $context, string $createdBy, bool $canEdit): void
|
||||
{
|
||||
$this->assertSame($canEdit, $this->service($context)->canEdit(self::source(['created_by' => $createdBy])));
|
||||
}
|
||||
|
||||
public function testWithoutAclNothingIsVisibleExceptForAdmins(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('findVisible')->willReturn(self::source());
|
||||
$this->repo->expects($this->once())->method('visibleIds')->willReturn([7]);
|
||||
|
||||
$this->assertNull($this->service(FakeContext::user('u@example.org', []))->get(7));
|
||||
$this->assertSame([], $this->service(FakeContext::domainAdmin([], [], []))->visibleIds());
|
||||
$this->assertNotNull($this->service(FakeContext::admin([]))->get(7));
|
||||
$this->assertSame([7], $this->service(FakeContext::admin([]))->visibleIds());
|
||||
}
|
||||
|
||||
public static function noEditors(): array
|
||||
{
|
||||
return [
|
||||
'not logged in' => [new FakeContext('', null)],
|
||||
'system' => [Context::system()],
|
||||
'user without ACL' => [FakeContext::user('u@example.org', [])],
|
||||
'domain admin without ACL' => [FakeContext::domainAdmin([], [], ['syncjobs' => '0'])],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('noEditors')]
|
||||
public function testCreateNeedsAnEditorWithAcl(Context $context): void
|
||||
{
|
||||
$this->repo->expects($this->never())->method('insert');
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $this->service($context)->create(['name' => 'a', 'host' => 'h.example.org', 'port' => 993]));
|
||||
}
|
||||
|
||||
public function testUserSourceIsAlwaysPrivate(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('insert')->with($this->callback(function (Source $s): bool {
|
||||
return $s->scope === 'user' && $s->domains === [] && $s->users === [] && $s->createdBy === 'u@example.org'
|
||||
&& $s->host === 'imap.example.org' && $s->port === 993 && $s->enc === 'TLS' && $s->active === 1;
|
||||
}));
|
||||
|
||||
$source = $this->service(FakeContext::user('u@example.org'))->create([
|
||||
'name' => ' Mine ', 'host' => 'IMAP.example.org', 'port' => '993', 'scope' => 'all', 'domains' => ['example.org'],
|
||||
]);
|
||||
|
||||
$this->assertSame('Mine', $source->name);
|
||||
}
|
||||
|
||||
public function testUsersMayNotCreateXOAuth2Sources(): void
|
||||
{
|
||||
$this->repo->expects($this->never())->method('insert');
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user())->create($this->oauthInput()));
|
||||
}
|
||||
|
||||
public function testDomainAdminScopeRules(): void
|
||||
{
|
||||
$service = $this->service(FakeContext::domainAdmin(['m@example.org'], ['example.org']));
|
||||
$input = ['name' => 'a', 'host' => 'imap.example.org', 'port' => 993];
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'all']));
|
||||
$this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'everyone']));
|
||||
$this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'domain', 'domains' => ['foreign.org']]));
|
||||
$this->expectUserError('access_denied', fn() => $service->create($input + ['scope' => 'user', 'users' => ['x@foreign.org']]));
|
||||
$this->expectUserError('imapsync_source_scope_empty', fn() => $service->create($input + ['scope' => 'domain', 'domains' => [' ', ['x']]]));
|
||||
}
|
||||
|
||||
public function testDomainScopeTargetsAreCleaned(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('insert')->with($this->callback(
|
||||
fn(Source $s): bool => $s->scope === 'domain' && $s->domains === ['example.org'] && $s->users === []
|
||||
));
|
||||
|
||||
$this->service(FakeContext::domainAdmin([], ['example.org']))->create([
|
||||
'name' => 'a', 'host' => 'imap.example.org', 'port' => 993,
|
||||
'scope' => 'domain', 'domains' => [' Example.org', 'example.org', ''], 'users' => ['ignored@example.org'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testClientCredentialsSourcesAreNeverShared(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('insert')->with($this->callback(
|
||||
fn(Source $s): bool => $s->scope === 'user' && $s->domains === [] && $s->usesClientCredentials()
|
||||
));
|
||||
|
||||
$this->service(FakeContext::admin())->create($this->oauthInput() + ['scope' => 'all']);
|
||||
}
|
||||
|
||||
public static function invalidBasics(): array
|
||||
{
|
||||
$valid = ['name' => 'a', 'description' => '', 'host' => 'imap.example.org', 'port' => 993, 'enc' => 'TLS', 'auth_type' => 'PLAIN'];
|
||||
return [
|
||||
'name missing' => [['name' => ''] + $valid, 'imapsync_source_name_invalid'],
|
||||
'name too long' => [['name' => str_repeat('n', 101)] + $valid, 'imapsync_source_name_invalid'],
|
||||
'name is array' => [['name' => ['x']] + $valid, 'imapsync_source_name_invalid'],
|
||||
'description too long' => [['description' => str_repeat('d', 256)] + $valid, 'imapsync_source_description_invalid'],
|
||||
'host invalid' => [['host' => 'imap_server.lan'] + $valid, 'imapsync_source_host_invalid'],
|
||||
'host too long' => [['host' => str_repeat('a.', 127) . 'org'] + $valid, 'imapsync_source_host_invalid'],
|
||||
'port 0' => [['port' => 0] + $valid, 'imapsync_source_port_invalid'],
|
||||
'port too high' => [['port' => 65536] + $valid, 'imapsync_source_port_invalid'],
|
||||
'enc unknown' => [['enc' => 'STARTTLS'] + $valid, 'imapsync_source_enc_invalid'],
|
||||
'auth unknown' => [['auth_type' => 'GSSAPI'] + $valid, 'imapsync_source_auth_invalid'],
|
||||
'name checked first' => [['name' => '', 'host' => '', 'port' => 0] + $valid, 'imapsync_source_name_invalid'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidBasics')]
|
||||
public function testCreateValidatesBasics(array $input, string $msg): void
|
||||
{
|
||||
$this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->create($input));
|
||||
}
|
||||
|
||||
public function testIpAddressAndLegacyAliasesAreAccepted(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('insert')->with($this->callback(
|
||||
fn(Source $s): bool => $s->host === '192.0.2.10' && $s->port === 143 && $s->enc === 'PLAIN'
|
||||
));
|
||||
|
||||
$this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']);
|
||||
}
|
||||
|
||||
public function testNameMustBeUniquePerCreator(): void
|
||||
{
|
||||
$this->repo->method('nameExists')->with('<b>', 'admin', null)->willReturn(true);
|
||||
$this->repo->expects($this->never())->method('insert');
|
||||
|
||||
$this->expectUserError(['object_exists', '<b>'], fn() => $this->service(FakeContext::admin())->create(
|
||||
['name' => '<b>', 'host' => 'imap.example.org', 'port' => 993]
|
||||
));
|
||||
}
|
||||
|
||||
public static function invalidOAuth(): array
|
||||
{
|
||||
return [
|
||||
'secret missing' => [['oauth_client_secret' => ''], 'imapsync_source_oauth_required'],
|
||||
'scope missing' => [['oauth_scope' => ''], 'imapsync_source_oauth_required'],
|
||||
'endpoint is array' => [['oauth_token_endpoint' => ['x']], 'imapsync_source_oauth_invalid'],
|
||||
'secret is array' => [['oauth_client_secret' => ['x']], 'imapsync_source_oauth_invalid'],
|
||||
'client id too long' => [['oauth_client_id' => str_repeat('c', 501)], 'imapsync_source_oauth_invalid'],
|
||||
'extra params list' => [['oauth_extra_params' => '[1,2]'], 'imapsync_source_oauth_extra_invalid'],
|
||||
'extra params nested' => [['oauth_extra_params' => ['a' => ['b' => 1]]], 'imapsync_source_oauth_extra_invalid'],
|
||||
'extra params no JSON' => [['oauth_extra_params' => '{nope'], 'imapsync_source_oauth_extra_invalid'],
|
||||
'extra params too long' => [['oauth_extra_params' => ['a' => str_repeat('x', 4000)]], 'imapsync_source_oauth_extra_invalid'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidOAuth')]
|
||||
public function testOAuthAttributesAreValidated(array $override, string $msg): void
|
||||
{
|
||||
$this->expectUserError($msg, fn() => $this->service(FakeContext::admin())->create($override + $this->oauthInput()));
|
||||
}
|
||||
|
||||
public function testExtraParamsAreAcceptedAsArrayOrString(): void
|
||||
{
|
||||
$stored = [];
|
||||
$this->repo->method('insert')->willReturnCallback(function (Source $s) use (&$stored): int {
|
||||
$stored[] = $s->extraParams;
|
||||
return 1;
|
||||
});
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$service->create(['oauth_extra_params' => ['resource' => 'https://x/', 'n' => 1]] + $this->oauthInput());
|
||||
$service->create(['oauth_extra_params' => '{"resource":"https://x/"}'] + $this->oauthInput());
|
||||
$service->create(['oauth_extra_params' => ' '] + $this->oauthInput());
|
||||
|
||||
$this->assertSame(['{"resource":"https://x/","n":1}', '{"resource":"https://x/"}', null], $stored);
|
||||
}
|
||||
|
||||
public function testEndpointsMustPassThePolicyOfTheCreator(): void
|
||||
{
|
||||
// Sources of a domain admin ('da', no superadmin) get the strict address policy for every endpoint
|
||||
$client = $this->createMock(OAuthClient::class);
|
||||
$client->expects($this->exactly(3))->method('isAllowed')
|
||||
->willReturnCallback(fn(string $url, bool $strict): bool => $strict && $url !== 'https://internal/userinfo');
|
||||
$this->repo->method('isSuperAdmin')->with('da')->willReturn(false);
|
||||
$service = $this->service(FakeContext::domainAdmin(), $client);
|
||||
|
||||
$this->expectUserError('imapsync_source_oauth_endpoint_invalid', fn() => $service->create([
|
||||
'oauth_flow' => 'authorization_code',
|
||||
'oauth_authorize_endpoint' => 'https://login.example.org/authorize',
|
||||
'oauth_userinfo_endpoint' => 'https://internal/userinfo',
|
||||
] + $this->oauthInput()));
|
||||
}
|
||||
|
||||
public function testUpdateNeedsVisibleEditableSource(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(null, self::source(['created_by' => 'x@example.org']));
|
||||
$this->repo->expects($this->never())->method('update');
|
||||
$service = $this->service(FakeContext::user('u@example.org'));
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b']));
|
||||
$this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'b']));
|
||||
}
|
||||
|
||||
public function testUsersMayNotSwitchToXOAuth2(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
|
||||
$this->repo->expects($this->never())->method('update');
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('u@example.org'))->update(7, $this->oauthInput()));
|
||||
}
|
||||
|
||||
public function testUnchangedLegacyValuesAreNotValidatedAgain(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::source(['host1' => 'imap_server.lan', 'enc1' => null]));
|
||||
$this->repo->expects($this->once())->method('update')->with(
|
||||
$this->callback(fn(Source $s): bool => $s->name === 'renamed' && $s->host === 'imap_server.lan' && $s->enc === null),
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
$this->service(FakeContext::admin())->update(7, ['name' => 'renamed', 'host' => 'IMAP_SERVER.lan']);
|
||||
}
|
||||
|
||||
public function testRenameMustStayUniqueForTheCreator(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::source());
|
||||
$this->repo->method('nameExists')->with('taken', 'owner@example.org', 7)->willReturn(true);
|
||||
|
||||
$this->expectUserError(['object_exists', 'taken'], fn() => $this->service(FakeContext::admin())->update(7, ['name' => 'taken']));
|
||||
}
|
||||
|
||||
public function testEmptySecretKeepsStoredOneAndTokensSurviveUnrelatedChanges(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::oauthSource());
|
||||
$this->repo->expects($this->once())->method('update')->with(
|
||||
$this->callback(fn(Source $s): bool => $s->clientSecret === 'stored-secret' && $s->oauthScope === 'new-scope'),
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
$this->service(FakeContext::domainAdmin())->update(7, ['oauth_client_secret' => '', 'oauth_scope' => 'new-scope']);
|
||||
}
|
||||
|
||||
public static function connectionChanges(): array
|
||||
{
|
||||
return [
|
||||
'token endpoint' => [['oauth_token_endpoint' => 'https://other.example.org/token']],
|
||||
'client id' => [['oauth_client_id' => 'other']],
|
||||
'host' => [['host' => 'other.example.org']],
|
||||
'port' => [['port' => 143]],
|
||||
'encryption' => [['enc' => 'TLS']],
|
||||
'flow' => [['oauth_flow' => 'authorization_code', 'oauth_authorize_endpoint' => 'https://login.example.org/a']],
|
||||
'auth type' => [['auth_type' => 'PLAIN']],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('connectionChanges')]
|
||||
public function testTokensAreResetWhenTheConnectionChanges(array $input): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::oauthSource());
|
||||
$this->repo->expects($this->once())->method('update')->with($this->anything(), $this->anything(), true);
|
||||
|
||||
$this->service(FakeContext::domainAdmin())->update(7, $input);
|
||||
}
|
||||
|
||||
public function testTargetsAreOnlyRewrittenWhenScopeIsSent(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::source(['scope' => 'domain']));
|
||||
$calls = [];
|
||||
$this->repo->method('update')->willReturnCallback(function (Source $s, bool $targets) use (&$calls): void {
|
||||
$calls[] = [$s->scope, $targets];
|
||||
});
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$service->update(7, ['name' => 'x']);
|
||||
$service->update(7, ['scope' => 'all']);
|
||||
|
||||
$this->assertSame([['domain', false], ['all', true]], $calls);
|
||||
}
|
||||
|
||||
public function testApiArrayNeverContainsSecrets(): void
|
||||
{
|
||||
$source = self::oauthSource(['oauth_extra_params' => '{"a":"b"}']);
|
||||
|
||||
$editor = $this->service(FakeContext::admin())->toArray($source);
|
||||
$viewer = $this->service(FakeContext::user('u@example.org'))->toArray($source);
|
||||
|
||||
foreach ([$editor, $viewer] as $data) {
|
||||
$this->assertArrayNotHasKey('oauth_client_secret', $data);
|
||||
$this->assertArrayNotHasKey('oauth_access_token', $data);
|
||||
$this->assertStringNotContainsString('stored-secret', json_encode($data));
|
||||
$this->assertTrue($data['has_client_secret']);
|
||||
$this->assertTrue($data['has_access_token']);
|
||||
$this->assertArrayNotHasKey('oauth_redirect_uri', $data);
|
||||
}
|
||||
$this->assertTrue($editor['can_edit']);
|
||||
$this->assertSame('{"a":"b"}', $editor['oauth_extra_params']);
|
||||
$this->assertFalse($viewer['can_edit']);
|
||||
$this->assertNull($viewer['oauth_extra_params']);
|
||||
}
|
||||
|
||||
public function testApiArrayOfAuthorizationCodeSourceHasRedirectUri(): void
|
||||
{
|
||||
$data = $this->service(FakeContext::admin())->toArray(self::oauthSource(['oauth_flow' => 'authorization_code']));
|
||||
|
||||
$this->assertSame('https://mail.example.org/syncjob-oauth', $data['oauth_redirect_uri']);
|
||||
$this->assertSame(['host' => 'imap.example.org', 'port' => 993, 'enc' => 'SSL'],
|
||||
array_intersect_key($data, ['host' => 1, 'port' => 1, 'enc' => 1]));
|
||||
}
|
||||
|
||||
public function testDeleteRefusesSourcesInUse(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturn(self::source());
|
||||
$this->repo->method('isInUse')->willReturnOnConsecutiveCalls(true, false);
|
||||
$this->repo->expects($this->once())->method('delete')->with(7);
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$this->expectUserError(['imapsync_source_in_use', 'src'], fn() => $service->delete(7));
|
||||
$this->assertSame('src', $service->delete(7)->name);
|
||||
}
|
||||
|
||||
public function testRefreshTokenRules(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(
|
||||
self::oauthSource(['oauth_flow' => 'authorization_code']),
|
||||
self::oauthSource(),
|
||||
self::oauthSource()
|
||||
);
|
||||
$this->client->method('request')->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new OAuthException('HTTP 401: invalid_client')),
|
||||
['access_token' => 'new']
|
||||
);
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::admin([]))->refreshToken(7));
|
||||
$this->expectUserError('imapsync_source_refresh_unsupported', fn() => $service->refreshToken(7));
|
||||
$this->expectUserError(['imapsync_source_token_refresh_failed', 'src (HTTP 401: invalid_client)'], fn() => $service->refreshToken(7));
|
||||
$this->assertSame('src', $service->refreshToken(7)->name);
|
||||
}
|
||||
|
||||
public function testConnectableSourceIsActiveAuthorizationCode(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(
|
||||
self::oauthSource(['oauth_flow' => 'authorization_code']),
|
||||
self::oauthSource(['oauth_flow' => 'authorization_code', 'active' => 0]),
|
||||
self::oauthSource()
|
||||
);
|
||||
$service = $this->service(FakeContext::user());
|
||||
|
||||
$this->assertNotNull($service->findConnectable(7));
|
||||
$this->assertNull($service->findConnectable(7));
|
||||
$this->assertNull($service->findConnectable(7));
|
||||
}
|
||||
|
||||
public function testAccountRenameReleasesLeftoversAndMovesEverything(): void
|
||||
{
|
||||
$this->repo->method('listCreatedBy')->with('new@example.org')->willReturn([
|
||||
self::source(['id' => 1, 'name' => 'unused']),
|
||||
self::source(['id' => 2, 'name' => 'used']),
|
||||
]);
|
||||
$this->repo->method('isInUse')->willReturnMap([[1, false], [2, true]]);
|
||||
$this->repo->method('nameExists')->with('used', '')->willReturn(true);
|
||||
$this->repo->expects($this->once())->method('deleteWithChildren')->with(1);
|
||||
$this->repo->expects($this->once())->method('handOver')->with(2, 'used (#2)');
|
||||
$this->repo->expects($this->once())->method('renameAccount')->with('old@example.org', 'new@example.org');
|
||||
$this->tokens->expects($this->once())->method('renameOwner')->with('old@example.org', 'new@example.org');
|
||||
|
||||
$this->service(Context::system())->onAccountRenamed('old@example.org', 'new@example.org');
|
||||
}
|
||||
|
||||
public function testAccountRenameToItselfDoesNothing(): void
|
||||
{
|
||||
$this->repo->expects($this->never())->method('listCreatedBy');
|
||||
$this->tokens->expects($this->never())->method('renameOwner');
|
||||
|
||||
$this->service(Context::system())->onAccountRenamed('a@example.org', 'a@example.org');
|
||||
$this->service(Context::system())->onAccountDeleted('');
|
||||
}
|
||||
|
||||
public function testAccountDeleteRemovesTokensAndTargets(): void
|
||||
{
|
||||
$this->repo->method('listCreatedBy')->with('old@example.org')->willReturn([self::source(['id' => 3, 'name' => str_repeat('n', 100)])]);
|
||||
$this->repo->method('isInUse')->willReturn(true);
|
||||
$this->repo->method('nameExists')->willReturn(true);
|
||||
$this->repo->expects($this->once())->method('handOver')->with(3, str_repeat('n', 80) . ' (#3)');
|
||||
$this->tokens->expects($this->once())->method('deleteByOwner')->with('old@example.org');
|
||||
$this->repo->expects($this->once())->method('deleteUserTarget')->with('old@example.org');
|
||||
|
||||
$this->service(Context::system())->onAccountDeleted('old@example.org');
|
||||
}
|
||||
|
||||
public function testAccountCascadeLetsDatabaseErrorsThrough(): void
|
||||
{
|
||||
$this->repo->method('listCreatedBy')->willThrowException(new PDOException('gone'));
|
||||
|
||||
$this->expectException(PDOException::class);
|
||||
$this->service(Context::system())->onAccountDeleted('old@example.org');
|
||||
}
|
||||
|
||||
private function oauthInput(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'oauth',
|
||||
'host' => 'outlook.office365.com',
|
||||
'port' => 993,
|
||||
'enc' => 'SSL',
|
||||
'auth_type' => 'XOAUTH2',
|
||||
'oauth_flow' => 'client_credentials',
|
||||
'oauth_token_endpoint' => 'https://login.example.org/token',
|
||||
'oauth_client_id' => 'client',
|
||||
'oauth_client_secret' => 'secret',
|
||||
'oauth_scope' => 'scope',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
|
||||
/**
|
||||
* Context with a fixed ACL world instead of the legacy hasMailboxObjectAccess()/hasDomainAccess() bridge:
|
||||
* like the legacy functions, the own mailbox is always accessible for a logged-in role.
|
||||
*/
|
||||
class FakeContext extends Context
|
||||
{
|
||||
private array $mailboxes;
|
||||
private array $domains;
|
||||
|
||||
public function __construct(string $role, ?string $username, array $acl = ['syncjobs' => '1'], bool $accessAll = false,
|
||||
array $mailboxes = [], array $domains = [])
|
||||
{
|
||||
parent::__construct($role, $username, $acl, $accessAll);
|
||||
$this->mailboxes = $mailboxes;
|
||||
$this->domains = $domains;
|
||||
}
|
||||
|
||||
public static function admin(array $acl = ['syncjobs' => '1']): self
|
||||
{
|
||||
return new self('admin', 'admin', $acl, false, ['*'], ['*']);
|
||||
}
|
||||
|
||||
public static function domainAdmin(array $mailboxes = [], array $domains = [], array $acl = ['syncjobs' => '1']): self
|
||||
{
|
||||
return new self('domainadmin', 'da', $acl, false, $mailboxes, $domains);
|
||||
}
|
||||
|
||||
public static function user(string $username = 'user@example.org', array $acl = ['syncjobs' => '1']): self
|
||||
{
|
||||
return new self('user', $username, $acl);
|
||||
}
|
||||
|
||||
public function canAccessMailbox(string $mailbox): bool
|
||||
{
|
||||
if ($this->accessAll) {
|
||||
return true;
|
||||
}
|
||||
if (!in_array($this->role, ['admin', 'domainadmin', 'user'], true) || $this->username === null) {
|
||||
return false;
|
||||
}
|
||||
return $mailbox === $this->username || in_array($mailbox, $this->mailboxes, true) || in_array('*', $this->mailboxes, true);
|
||||
}
|
||||
|
||||
public function canAccessDomain(string $domain): bool
|
||||
{
|
||||
return $this->accessAll || in_array($domain, $this->domains, true) || in_array('*', $this->domains, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
use Mailcow\Imapsync\Http\OAuthClient;
|
||||
|
||||
/**
|
||||
* OAuthClient without network: resolve() answers from $addresses, send() records the curl options
|
||||
* and returns $response.
|
||||
*/
|
||||
class FakeOAuthClient extends OAuthClient
|
||||
{
|
||||
/** @var array<string, string[]> host => addresses */
|
||||
public array $addresses = [];
|
||||
/** @var array{0: string|false, 1: int} */
|
||||
public array $response = ['{}', 200];
|
||||
/** @var array[] curl options of every send() */
|
||||
public array $sent = [];
|
||||
/** @var string[] hosts passed to resolve() */
|
||||
public array $resolved = [];
|
||||
|
||||
protected function resolve(string $host): array
|
||||
{
|
||||
$this->resolved[] = $host;
|
||||
return $this->addresses[$host] ?? [];
|
||||
}
|
||||
|
||||
protected function send(array $options): array
|
||||
{
|
||||
$this->sent[] = $options;
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
/**
|
||||
* Calls recorded by the legacy ACL stand-ins in legacy_acl_functions.php.
|
||||
*/
|
||||
class LegacyAclCalls
|
||||
{
|
||||
public static array $calls = [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
// Stand-ins for the legacy ACL functions of data/web/inc/functions.inc.php, used by ContextTest only:
|
||||
// they record the call and answer from a fixed table.
|
||||
|
||||
use Mailcow\Tests\Support\LegacyAclCalls;
|
||||
|
||||
if (!function_exists('hasMailboxObjectAccess')) {
|
||||
function hasMailboxObjectAccess($username, $role, $object)
|
||||
{
|
||||
LegacyAclCalls::$calls[] = ['mailbox', $username, $role, $object];
|
||||
return $object === 'allowed@example.org';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('hasDomainAccess')) {
|
||||
function hasDomainAccess($username, $role, $domain)
|
||||
{
|
||||
LegacyAclCalls::$calls[] = ['domain', $username, $role, $domain];
|
||||
return $domain === 'example.org';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Production autoloader first: the tests must find the classes through the PSR-4 mapping
|
||||
// of data/web/inc/lib/composer.json, exactly as php-fpm does.
|
||||
require __DIR__ . '/../../data/web/inc/lib/vendor/autoload.php';
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "mailcow/php-tests",
|
||||
"description": "Dev-only PHPUnit setup for data/web/inc/lib/mailcow (never deployed)",
|
||||
"type": "project",
|
||||
"license": "GPL-3.0-only",
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Mailcow\\Tests\\": ""
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.2.29"
|
||||
},
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
Generated
+1786
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
colors="true"
|
||||
failOnWarning="true"
|
||||
failOnRisky="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
beStrictAboutOutputDuringTests="true">
|
||||
<testsuites>
|
||||
<testsuite name="mailcow">
|
||||
<directory>.</directory>
|
||||
<exclude>vendor</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>../../data/web/inc/lib/mailcow</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
Reference in new issue
Block a user