diff --git a/.gitignore b/.gitignore index f7c6f512e..b5988ba55 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,6 @@ refresh_images.sh update_diffs/ create_cold_standby.sh !data/conf/nginx/mailcow_auth.conf -data/conf/postfix/postfix-tlspol \ No newline at end of file +data/conf/postfix/postfix-tlspol +tests/php/vendor/ +tests/php/.phpunit.cache/ diff --git a/helper-scripts/dev_tests/run_php_unit_tests.sh b/helper-scripts/dev_tests/run_php_unit_tests.sh new file mode 100755 index 000000000..91f4e5022 --- /dev/null +++ b/helper-scripts/dev_tests/run_php_unit_tests.sh @@ -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 "$@" diff --git a/tests/php/Core/ApiResponseTest.php b/tests/php/Core/ApiResponseTest.php new file mode 100644 index 000000000..c6c4a7503 --- /dev/null +++ b/tests/php/Core/ApiResponseTest.php @@ -0,0 +1,92 @@ +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); + } +} diff --git a/tests/php/Core/ContextTest.php b/tests/php/Core/ContextTest.php new file mode 100644 index 000000000..654065887 --- /dev/null +++ b/tests/php/Core/ContextTest.php @@ -0,0 +1,84 @@ + '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); + } +} diff --git a/tests/php/Imapsync/Controller/ApiControllerTest.php b/tests/php/Imapsync/Controller/ApiControllerTest.php new file mode 100644 index 000000000..114e29ba9 --- /dev/null +++ b/tests/php/Imapsync/Controller/ApiControllerTest.php @@ -0,0 +1,306 @@ +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']); + } +} diff --git a/tests/php/Imapsync/Http/OAuthClientTest.php b/tests/php/Imapsync/Http/OAuthClientTest.php new file mode 100644 index 000000000..5564a9f74 --- /dev/null +++ b/tests/php/Imapsync/Http/OAuthClientTest.php @@ -0,0 +1,202 @@ +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' => [['internal details', 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\":\"\\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'); + } +} diff --git a/tests/php/Imapsync/Model/CustomParamsTest.php b/tests/php/Imapsync/Model/CustomParamsTest.php new file mode 100644 index 000000000..924db327b --- /dev/null +++ b/tests/php/Imapsync/Model/CustomParamsTest.php @@ -0,0 +1,146 @@ + 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' => '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); + } +} diff --git a/tests/php/Imapsync/Service/JobServiceTest.php b/tests/php/Imapsync/Service/JobServiceTest.php new file mode 100644 index 000000000..c2d4d4431 --- /dev/null +++ b/tests/php/Imapsync/Service/JobServiceTest.php @@ -0,0 +1,371 @@ + 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' => ''])); + $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()); + } +} diff --git a/tests/php/Imapsync/Service/OAuthServiceTest.php b/tests/php/Imapsync/Service/OAuthServiceTest.php new file mode 100644 index 000000000..d18b739bb --- /dev/null +++ b/tests/php/Imapsync/Service/OAuthServiceTest.php @@ -0,0 +1,260 @@ +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()); + } + } +} diff --git a/tests/php/Imapsync/Service/SourceServiceTest.php b/tests/php/Imapsync/Service/SourceServiceTest.php new file mode 100644 index 000000000..54286ef5a --- /dev/null +++ b/tests/php/Imapsync/Service/SourceServiceTest.php @@ -0,0 +1,506 @@ +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('', 'admin', null)->willReturn(true); + $this->repo->expects($this->never())->method('insert'); + + $this->expectUserError(['object_exists', '<b>'], fn() => $this->service(FakeContext::admin())->create( + ['name' => '', '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', + ]; + } +} diff --git a/tests/php/Support/FakeContext.php b/tests/php/Support/FakeContext.php new file mode 100644 index 000000000..8a868a63a --- /dev/null +++ b/tests/php/Support/FakeContext.php @@ -0,0 +1,56 @@ + '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); + } +} diff --git a/tests/php/Support/FakeOAuthClient.php b/tests/php/Support/FakeOAuthClient.php new file mode 100644 index 000000000..5c0e16a05 --- /dev/null +++ b/tests/php/Support/FakeOAuthClient.php @@ -0,0 +1,35 @@ + 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; + } +} diff --git a/tests/php/Support/LegacyAclCalls.php b/tests/php/Support/LegacyAclCalls.php new file mode 100644 index 000000000..bdee3aef0 --- /dev/null +++ b/tests/php/Support/LegacyAclCalls.php @@ -0,0 +1,13 @@ +=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.57", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/36b75bf56a8535623f5b3e24b27ea1823759da1a", + "reference": "36b75bf56a8535623f5b3e24b27ea1823759da1a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.57" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:47:54+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.29" + }, + "plugin-api-version": "2.9.0" +} diff --git a/tests/php/phpunit.xml.dist b/tests/php/phpunit.xml.dist new file mode 100644 index 000000000..27aa18324 --- /dev/null +++ b/tests/php/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + . + vendor + + + + + ../../data/web/inc/lib/mailcow + + +