[Web][Tests] Add PHPUnit setup and imapsync module tests

This commit is contained in:
FreddleSpl0it committed 2026-10-22 10:07:52 +02:00
1 parent b2a152fe45
commit e756b7857e
18 files changed
+3948 -1

No files matched your search

@@ -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', '&lt;src&gt; / 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', '&lt;b&gt;'], 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',
];
}
}