From fc56d53029847060182dbb98edef0f734f52c36f Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:18:58 +0100 Subject: [PATCH] [Web][Tests] Add archive module tests --- .../Controller/ArchiveApiControllerTest.php | 101 ++++++++++ tests/php/Archive/Model/MailboxTest.php | 60 ++++++ tests/php/Archive/Model/SettingsTest.php | 51 +++++ tests/php/Archive/Redis/ArchiveStateTest.php | 79 ++++++++ .../php/Archive/Service/HeaderBuilderTest.php | 105 ++++++++++ .../Archive/Service/JournalServiceTest.php | 166 +++++++++++++++ .../Service/MailboxListServiceTest.php | 69 +++++++ .../php/Archive/Service/OwnerResolverTest.php | 158 +++++++++++++++ .../Archive/Service/SettingsServiceTest.php | 189 ++++++++++++++++++ .../php/Archive/Service/StatusServiceTest.php | 115 +++++++++++ tests/php/Archive/Smtp/SmtpClientTest.php | 123 ++++++++++++ tests/php/Support/FakeArchiveLookup.php | 87 ++++++++ tests/php/Support/FakeRedis.php | 54 +++++ tests/php/Support/FakeSmtpClient.php | 46 +++++ 14 files changed, 1403 insertions(+) create mode 100644 tests/php/Archive/Controller/ArchiveApiControllerTest.php create mode 100644 tests/php/Archive/Model/MailboxTest.php create mode 100644 tests/php/Archive/Model/SettingsTest.php create mode 100644 tests/php/Archive/Redis/ArchiveStateTest.php create mode 100644 tests/php/Archive/Service/HeaderBuilderTest.php create mode 100644 tests/php/Archive/Service/JournalServiceTest.php create mode 100644 tests/php/Archive/Service/MailboxListServiceTest.php create mode 100644 tests/php/Archive/Service/OwnerResolverTest.php create mode 100644 tests/php/Archive/Service/SettingsServiceTest.php create mode 100644 tests/php/Archive/Service/StatusServiceTest.php create mode 100644 tests/php/Archive/Smtp/SmtpClientTest.php create mode 100644 tests/php/Support/FakeArchiveLookup.php create mode 100644 tests/php/Support/FakeRedis.php create mode 100644 tests/php/Support/FakeSmtpClient.php diff --git a/tests/php/Archive/Controller/ArchiveApiControllerTest.php b/tests/php/Archive/Controller/ArchiveApiControllerTest.php new file mode 100644 index 000000000..dc4b44072 --- /dev/null +++ b/tests/php/Archive/Controller/ArchiveApiControllerTest.php @@ -0,0 +1,101 @@ +settings = $this->createMock(SettingsService::class); + $this->status = $this->createMock(StatusService::class); + $this->journal = $this->createMock(JournalService::class); + $this->mailboxes = $this->createMock(MailboxListService::class); + $this->api = new ArchiveApiController($this->settings, $this->status, $this->journal, $this->mailboxes, + new ApiResponse(['password', 'signing_secret'])); + } + + /** 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 testSaveLogsWithoutSecrets(): void + { + $this->settings->expects($this->once())->method('save')->willReturn(new Settings()); + + $this->assertTrue($this->api->edit(['enabled' => '1', 'username' => 'mailcow', 'password' => 'pw', 'signing_secret' => 'key'])); + + $this->assertSame([[ + 'type' => 'success', + 'log' => ['archive', 'edit', 'settings', ['enabled' => '1', 'username' => 'mailcow', 'password' => '*', 'signing_secret' => '*']], + 'msg' => 'archive_settings_saved', + ]], $_SESSION['return']); + } + + public function testActionsAndErrors(): void + { + $this->settings->method('test')->willReturnOnConsecutiveCalls( + ['archive_test_ok_missing', '8BITMIME'], + $this->throwException(new UserError('archive_test_failed', ['archive.example:25 connect: Connection refused'])), + ); + $this->journal->method('testMail')->willReturn('4XyZ<1>'); + $this->settings->method('save')->willThrowException(new PDOException('gone away')); + + $this->assertTrue($this->api->edit(['action' => 'test'])); + $this->assertFalse($this->api->edit(['action' => 'test'])); + $this->assertTrue($this->api->edit(['action' => 'testmail'])); + $this->assertFalse($this->api->edit(['address' => 'journal@archive.example'])); + + $this->assertSame([ + ['warning', ['archive_test_ok_missing', '8BITMIME']], + ['danger', ['archive_test_failed', 'archive.example:25 connect: Connection refused']], + ['success', ['archive_testmail_queued', '4XyZ<1>']], + ['danger', ['mysql_error', 'gone away']], + ], $this->messages()); + } + + public function testActionsNeedWriteAccess(): void + { + $this->journal->method('testMail')->willThrowException(new AccessDenied()); + $this->settings->expects($this->never())->method('save'); + + $this->assertFalse($this->api->edit(['action' => 'testmail'])); + $this->assertSame([['danger', 'access_denied']], $this->messages()); + } + + public function testReadsAreEmptyWhenDenied(): void + { + $this->settings->method('get')->willThrowException(new AccessDenied()); + $this->status->method('status')->willThrowException(new AccessDenied()); + $this->status->method('log')->willThrowException(new AccessDenied()); + $this->mailboxes->method('archived')->willReturn(['enabled' => 1, 'mailboxes' => []]); + + $this->assertSame([], $this->api->getSettings()); + $this->assertSame([], $this->api->getStatus()); + $this->assertSame([], $this->api->getLog()); + $this->assertSame(['enabled' => 1, 'mailboxes' => []], $this->api->getMailboxes()); + } +} diff --git a/tests/php/Archive/Model/MailboxTest.php b/tests/php/Archive/Model/MailboxTest.php new file mode 100644 index 000000000..954788b84 --- /dev/null +++ b/tests/php/Archive/Model/MailboxTest.php @@ -0,0 +1,60 @@ + ['inherit', 1, true], + 'inherit, domain off' => ['inherit', 0, false], + 'missing attribute' => [null, 1, true], + 'unknown value' => ['yes', 0, false], + 'on' => ['1', 0, true], + 'on as number' => [1, 0, true], + 'off' => ['0', 1, false], + 'off as number' => [0, 1, false], + ]; + } + + #[DataProvider('settings')] + public function testEffective(mixed $archive, int $domainArchive, bool $archived): void + { + $this->assertSame($archived, Mailbox::effective($archive, $domainArchive)); + } + + public function testListOfArchivedMailboxes(): void + { + $lookup = new FakeArchiveLookup(); + $lookup->mailboxes = ['b@firma.de' => ['inherit', 1], 'a@other.de' => ['1', 0], 'c@firma.de' => ['0', 1]]; + $settings = new Settings(); + $repo = $this->createMock(SettingsRepository::class); + $repo->method('load')->willReturnCallback(fn(): Settings => $settings); + $readOnly = new Context('admin', 'API', [], false, 'ro'); + + $this->assertSame(['enabled' => 0, 'mailboxes' => []], (new MailboxListService($lookup, $repo, $readOnly))->archived()); + + $settings->enabled = true; + $this->assertSame(['enabled' => 1, 'mailboxes' => [ + ['username' => 'b@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'domain'], + ['username' => 'a@other.de', 'domain' => 'other.de', 'name' => '', 'active' => 1, 'source' => 'mailbox'], + ]], (new MailboxListService($lookup, $repo, $readOnly))->archived()); + + $this->expectException(UserError::class); + (new MailboxListService($lookup, $repo, FakeContext::domainAdmin()))->archived(); + } +} diff --git a/tests/php/Archive/Model/SettingsTest.php b/tests/php/Archive/Model/SettingsTest.php new file mode 100644 index 000000000..8daa755e0 --- /dev/null +++ b/tests/php/Archive/Model/SettingsTest.php @@ -0,0 +1,51 @@ + ['', 25, 'archive.example'], + 'mx, other port' => ['', 2525, 'archive.example:2525'], + 'host' => ['smtp.archive.example', 25, '[smtp.archive.example]:25'], + 'ipv4' => ['192.0.2.5', 587, '[192.0.2.5]:587'], + 'ipv6' => ['2001:db8::25', 25, '[ipv6:2001:db8::25]:25'], + ]; + } + + #[DataProvider('nexthops')] + public function testNexthop(string $host, int $port, string $nexthop): void + { + $settings = new Settings(); + $settings->address = 'journal@archive.example'; + $settings->host = $host; + $settings->port = $port; + + $this->assertSame($nexthop, $settings->nexthop()); + } + + public function testCanDeliverAndNoSecretsInTheApi(): void + { + $settings = new Settings(); + $this->assertFalse($settings->canDeliver()); + $settings->address = 'journal@archive.example'; + $this->assertFalse($settings->canDeliver()); + $settings->profile = 'envelope'; + $this->assertTrue($settings->canDeliver()); + + $settings->password = 'password-value'; + $settings->signingKey = 'signing-key-value'; + $api = json_encode($settings->toArray()); + $this->assertStringNotContainsString('password-value', $api); + $this->assertStringNotContainsString('signing-key-value', $api); + $this->assertSame([1, 1], [$settings->toArray()['has_password'], $settings->toArray()['has_signing_secret']]); + } +} diff --git a/tests/php/Archive/Redis/ArchiveStateTest.php b/tests/php/Archive/Redis/ArchiveStateTest.php new file mode 100644 index 000000000..e68088ba1 --- /dev/null +++ b/tests/php/Archive/Redis/ArchiveStateTest.php @@ -0,0 +1,79 @@ +redis = new FakeRedis(); + $this->state = new ArchiveState($this->redis); + } + + /** A Postfix log entry as syslog-ng pushes it (newest first) */ + private function log(string $program, string $message, int $time): void + { + $this->redis->lPush('POSTFIX_MAILLOG', json_encode(['time' => $time, 'program' => $program, 'message' => $message])); + } + + public function testSwitchIsMirroredForRspamd(): void + { + $this->state->setEnabled(true); + $this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']); + $this->state->setEnabled(false); + $this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']); + } + + public function testDroppedCopiesAreCountedAndTheLast100Listed(): void + { + for ($i = 1; $i <= 105; $i++) { + $this->state->dropped('Q' . $i, 'owner lookup failed', 'm' . $i . '@firma.de'); + } + + $stats = $this->state->droppedStats(2); + $this->assertSame(105, $stats['count']); + $this->assertSame(['Q105', 'Q104'], array_column($stats['recent'], 'qid')); + $this->assertSame(['owner lookup failed', 'm105@firma.de'], [$stats['recent'][0]['reason'], $stats['recent'][0]['message_id']]); + $this->assertCount(100, $this->redis->lists['ARCHIVE_DROPPED_LOG']); + } + + public function testEventsOfTheArchiveServiceOnly(): void + { + $this->log('postfix/cleanup', '1A2B: message-id=', 1700000000); + $this->log('postfix/archive/smtp', '1A2B: to=, relay=archive.example[192.0.2.5]:25, delay=0.2, dsn=2.0.0, ' + . 'status=sent (250 2.0.0 Ok: queued as X1)', 1700000001); + $this->log('postfix/smtp', '3C4D: to=, relay=mx.extern.example[192.0.2.9]:25, dsn=2.0.0, status=sent (250 ok)', 1700000002); + // notices about copies are dropped by archive_discard, they are no deliveries + $this->log('postfix/archive/discard', '5E6F: to=, relay=none, dsn=2.0.0, status=sent (discards mail)', 1700000003); + $this->log('postfix/archive/smtp', 'connect to archive.example[192.0.2.5]:25: Connection refused', 1700000004); + $this->log('postfix/archive/smtp', '7A8B: to=, relay=archive.example[192.0.2.5]:25, dsn=5.1.1, ' + . 'status=SOFTBOUNCE (host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command))', 1700000005); + $this->redis->lPush('POSTFIX_MAILLOG', 'no json'); + + $this->assertSame([ + ['time' => 1700000005, 'status' => 'softbounce', 'qid' => '7A8B', 'message_id' => '', + 'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'], + ['time' => 1700000001, 'status' => 'sent', 'qid' => '1A2B', 'message_id' => 'orig@extern.example', 'text' => '250 2.0.0 Ok: queued as X1'], + ], $this->state->events()); + $this->assertCount(1, $this->state->events(1)); + } + + public function testWithoutRedisNothingHappens(): void + { + $state = new ArchiveState(null); + $state->setEnabled(true); + $state->dropped('Q1', 'reason'); + + $this->assertSame(['count' => 0, 'recent' => []], $state->droppedStats()); + $this->assertSame([], $state->events()); + } +} diff --git a/tests/php/Archive/Service/HeaderBuilderTest.php b/tests/php/Archive/Service/HeaderBuilderTest.php new file mode 100644 index 000000000..c284a8a63 --- /dev/null +++ b/tests/php/Archive/Service/HeaderBuilderTest.php @@ -0,0 +1,105 @@ +profile = $profile; + $settings->signingKey = 'key'; + return $settings; + } + + private static function journal(array $rcpts = ['anna@firma.de', 'bcc@extern.example'], string $from = 'x@extern.example'): Journal + { + return new Journal(['anna@firma.de'], $rcpts, $from, '4XyZ1', 'internal', 'smtp', 1700000000.25, str_repeat('a', 64)); + } + + public function testArchivecowHeadersAndSignature(): void + { + $block = (new HeaderBuilder())->build(self::settings('archivecow'), self::journal()); + + $this->assertSame("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de,bcc@extern.example\r\n" + . "X-Archive-From: x@extern.example\r\nX-Archive-Queue-ID: 4XyZ1\r\nX-Archive-Received: 2023-11-14T22:13:20.250Z\r\n" + . "X-Archive-Direction: internal\r\nX-Archive-Source: smtp\r\nX-Archive-SHA256: " . str_repeat('a', 64) . "\r\n", + substr($block, 0, strpos($block, 'X-Archive-Signature:'))); + $signed = implode("\n", ['x-archive-owner:anna@firma.de', 'x-archive-rcpt:anna@firma.de,bcc@extern.example', + 'x-archive-from:x@extern.example', 'x-archive-queue-id:4XyZ1', 'x-archive-received:2023-11-14T22:13:20.250Z', + 'x-archive-direction:internal', 'x-archive-source:smtp', 'x-archive-sha256:' . str_repeat('a', 64)]); + $this->assertStringEndsWith("X-Archive-Signature: v=1; a=hmac-sha256; s=" . hash_hmac('sha256', $signed, 'key') . "\r\n", $block); + } + + /** Fixed vector shared with archivecow: if this changes, every archive that verifies the signature refuses the copies */ + public function testWireFormatIsStable(): void + { + $message = "From: sender@example.com\r\nTo: anna.schmidt@example.org\r\nSubject: Test\r\nMessage-ID: <1@example.com>\r\n\r\nHallo.\r\n"; + $settings = self::settings('archivecow'); + $settings->signingKey = 'test-signing-key'; + $journal = new Journal(['anna.schmidt@example.org', 'buchhaltung@example.org'], + ['anna.schmidt@example.org', 'buchhaltung@example.org', 'extern.partner@example.net'], + 'sender@example.com', '4ZxYw1abcDz9', 'in', 'smtp', 1793983297.25, hash('sha256', $message)); + + $this->assertSame('0042f6cd8a846e5ce8f4a57c97121324c1213380fc204736d15ffe5693a9ef5c', hash('sha256', $message)); + $this->assertSame("X-Archive-Owner: anna.schmidt@example.org,buchhaltung@example.org\r\n" + . "X-Archive-Rcpt: anna.schmidt@example.org,buchhaltung@example.org,\r\n extern.partner@example.net\r\n" + . "X-Archive-From: sender@example.com\r\n" + . "X-Archive-Queue-ID: 4ZxYw1abcDz9\r\n" + . "X-Archive-Received: 2026-11-06T16:41:37.250Z\r\n" + . "X-Archive-Direction: in\r\n" + . "X-Archive-Source: smtp\r\n" + . "X-Archive-SHA256: 0042f6cd8a846e5ce8f4a57c97121324c1213380fc204736d15ffe5693a9ef5c\r\n" + . "X-Archive-Signature: v=1; a=hmac-sha256; s=443c927e165fd1f418fdfde2be54645d3201ef3b17d9e74ed3569d937fcf84d1\r\n", + (new HeaderBuilder())->build($settings, $journal)); + } + + public function testLongListsAreFoldedAndSignedUnfolded(): void + { + $rcpts = array_map(fn(int $i): string => sprintf('recipient%04d@extern.example', $i), range(1, 200)); + $block = (new HeaderBuilder())->build(self::settings('archivecow'), self::journal($rcpts)); + + foreach (explode("\r\n", $block) as $line) { + $this->assertLessThanOrEqual(78 + 30, strlen($line)); + } + preg_match('/^X-Archive-Rcpt: (.*?)\r\n(?=\S)/ms', $block, $m); + $this->assertSame($rcpts, explode(',', preg_replace('/\r\n /', '', $m[1]))); + // the signature covers the values without whitespace, so folding does not matter + $this->assertStringContainsString(hash_hmac('sha256', implode("\n", [ + 'x-archive-owner:anna@firma.de', 'x-archive-rcpt:' . implode(',', $rcpts), 'x-archive-from:x@extern.example', + 'x-archive-queue-id:4XyZ1', 'x-archive-received:2023-11-14T22:13:20.250Z', 'x-archive-direction:internal', + 'x-archive-source:smtp', 'x-archive-sha256:' . str_repeat('a', 64)]), 'key'), $block); + } + + public function testEmptyValuesAreLeftOutAndControlCharactersRemoved(): void + { + $journal = new Journal(["anna@firma.de\r\nX-Evil: 1"], ['a@b.example', ''], '', '', 'in', 'quarantine', 1700000000.0, ''); + $block = (new HeaderBuilder())->build(self::settings('archivecow'), $journal); + + $this->assertStringNotContainsString("\r\nX-Evil", $block); + $this->assertStringNotContainsString('X-Archive-From', $block); + $this->assertStringNotContainsString('X-Archive-Queue-ID', $block); + $this->assertStringNotContainsString('X-Archive-SHA256', $block); + $this->assertStringContainsString("X-Archive-Owner: anna@firma.de X-Evil: 1\r\n", $block); + $this->assertStringContainsString("X-Archive-Rcpt: a@b.example\r\n", $block); + } + + public function testEnvelopeAndNoneProfiles(): void + { + $settings = self::settings('envelope'); + $settings->headerFrom = 'X-Real-MailFrom'; + $settings->headerRcpt = 'X-Real-RcptTo'; + $this->assertSame("X-Real-MailFrom: x@extern.example\r\nX-Real-RcptTo: anna@firma.de,bcc@extern.example\r\n", + (new HeaderBuilder())->build($settings, self::journal())); + $this->assertSame("X-Real-MailFrom: <>\r\nX-Real-RcptTo: anna@firma.de,bcc@extern.example\r\n", + (new HeaderBuilder())->build($settings, self::journal(from: ''))); + $this->assertSame('', (new HeaderBuilder())->build(self::settings('none'), self::journal())); + } +} diff --git a/tests/php/Archive/Service/JournalServiceTest.php b/tests/php/Archive/Service/JournalServiceTest.php new file mode 100644 index 000000000..50faa3099 --- /dev/null +++ b/tests/php/Archive/Service/JournalServiceTest.php @@ -0,0 +1,166 @@ +stored = new Settings(); + $this->stored->enabled = true; + $this->stored->address = 'journal@archive.example'; + $this->stored->signingKey = 'key'; + $this->lookup = new FakeArchiveLookup(); + $this->lookup->mailboxes = ['anna@firma.de' => ['inherit', 1], 'olga@other.de' => ['inherit', 0]]; + foreach (array_keys($this->lookup->mailboxes) as $username) { + $this->lookup->aliases[$username] = $username; + } + $this->redis = new FakeRedis(); + $this->smtp = new FakeSmtpClient(); + } + + private function service(?Context $context = null): JournalService + { + $repo = $this->createStub(SettingsRepository::class); + $repo->method('load')->willReturnCallback(fn(): Settings => clone $this->stored); + return new JournalService($repo, new OwnerResolver($this->lookup), new HeaderBuilder(), $this->smtp, + new ArchiveState($this->redis), $context ?? Context::system()); + } + + 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()); + } + } + + /** Header block and mail of a submitted copy, split after X-Archive-Signature */ + private static function split(string $data): array + { + $end = strpos($data, "\r\n", (int)strpos($data, 'X-Archive-Signature: ')) + 2; + return [substr($data, 0, $end), substr($data, $end)]; + } + + public function testPrepareForAnArchivedRecipient(): void + { + $sha256 = hash('sha256', 'mail'); + $result = $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de', 'olga@other.de'], 'user' => '', + 'bcc' => true, 'qid' => '4XyZ1', 'received' => 1700000000.25, 'sha256' => $sha256]); + + $this->assertSame(['archive' => true, 'rcpt' => 'journal@archive.example', 'sender' => 'archive-bounce@localhost'], + array_slice($result, 0, 3)); + $this->assertStringStartsWith("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de,olga@other.de\r\n" + . "X-Archive-From: x@extern.example\r\nX-Archive-Queue-ID: 4XyZ1\r\nX-Archive-Received: 2023-11-14T22:13:20.250Z\r\n" + . "X-Archive-Direction: in\r\nX-Archive-Source: smtp\r\nX-Archive-SHA256: $sha256\r\nX-Archive-Signature: ", $result['headers']); + $this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']); + } + + public function testPrepareWithoutCopy(): void + { + $this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['olga@other.de']])); + + $this->stored->signingKey = ''; + $this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de']])); + + $this->stored->enabled = false; + $this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de']])); + $this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']); + $this->assertSame([], $this->smtp->submitted); + } + + public function testPrepareCleansTheRequest(): void + { + $result = $this->service()->prepare(['from' => "x@extern.example\r\nBcc: y@extern.example", 'rcpt' => ['anna@firma.de', ['x'], 5], + 'qid' => str_repeat('Q', 100), 'received' => 'yesterday', 'sha256' => 'ABC']); + + $this->assertTrue($result['archive']); + $this->assertStringContainsString("\r\nX-Archive-From: x@extern.example Bcc: y@extern.example\r\n", $result['headers']); + $this->assertStringContainsString("\r\nX-Archive-Rcpt: anna@firma.de\r\n", $result['headers']); + $this->assertStringContainsString("\r\nX-Archive-Queue-ID: " . str_repeat('Q', 64) . "\r\n", $result['headers']); + $this->assertMatchesRegularExpression('/\r\nX-Archive-Received: \d{4}-\d\d-\d\dT[\d:.]+Z\r\n/', $result['headers']); + $this->assertStringNotContainsString('X-Archive-SHA256', $result['headers']); + } + + public function testCopyOfADeliveredMail(): void + { + $this->assertTrue($this->service()->submitDelivered("Subject: hi\n\n.body\n", 'x@extern.example', ['anna@firma.de'], 'quarantine')); + + $sent = $this->smtp->submitted[0]; + $this->assertSame(['postfix', 591, 'archive-bounce@localhost', 'journal@archive.example'], + [$sent['host'], $sent['port'], $sent['from'], $sent['rcpt']]); + [$headers, $message] = self::split($sent['data']); + $this->assertSame("Subject: hi\r\n\r\n.body\r\n", $message); + $this->assertStringStartsWith("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de\r\n", $headers); + $this->assertStringContainsString("\r\nX-Archive-Source: quarantine\r\nX-Archive-SHA256: " . hash('sha256', $message) . "\r\n", $headers); + } + + public function testCopyOfADeliveredMailNeverThrows(): void + { + $this->assertFalse($this->service()->submitDelivered("x\r\n", 'x@extern.example', ['olga@other.de'], 'massmail')); + $this->assertSame([], $this->smtp->submitted); + + $this->smtp->error = new SmtpException('connect', 'Connection refused'); + $errorLog = ini_set('error_log', '/dev/null'); + try { + $this->assertFalse($this->service()->submitDelivered("Message-ID: \r\nSubject: x\r\n\r\nx\r\n", + 'x@extern.example', ['anna@firma.de'], 'massmail')); + } finally { + ini_set('error_log', (string)$errorLog); + } + $this->assertSame('1', $this->redis->strings['ARCHIVE_DROPPED']); + // the Message-ID finds the mail in the archive log + $this->assertSame(['qid' => '', 'reason' => 'massmail: connect: Connection refused', 'message_id' => 'mm1@firma.de'], + array_slice(json_decode($this->redis->lists['ARCHIVE_DROPPED_LOG'][0], true), 1)); + } + + public function testTestMail(): void + { + $this->stored->enabled = false; + + $this->assertSame('Q1', $this->service(FakeContext::admin())->testMail()); + + [$headers, $message] = self::split($this->smtp->submitted[0]['data']); + $this->assertStringStartsWith("X-Archive-Rcpt: journal@archive.example\r\nX-Archive-From: archive-bounce@localhost\r\n", $headers); + $this->assertStringContainsString("\r\nX-Archive-Source: test\r\n", $headers); + $this->assertStringContainsString("\r\nSubject: mailcow archive test\r\n", $message); + } + + public function testTestMailErrors(): void + { + $this->expectUserError('access_denied', fn() => $this->service(new Context('admin', 'API', [], false, 'ro'))->testMail()); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->testMail()); + + $this->smtp->error = new SmtpException('recipient', '550 5.7.1 : relay denied'); + $this->expectUserError(['archive_testmail_failed', 'recipient: 550 5.7.1 journal@archive.example: relay denied'], + fn() => $this->service(FakeContext::admin())->testMail()); + + $this->stored->address = ''; + $this->expectUserError('archive_incomplete', fn() => $this->service(FakeContext::admin())->testMail()); + $this->assertSame([], $this->smtp->submitted); + } +} diff --git a/tests/php/Archive/Service/MailboxListServiceTest.php b/tests/php/Archive/Service/MailboxListServiceTest.php new file mode 100644 index 000000000..f1a11ebeb --- /dev/null +++ b/tests/php/Archive/Service/MailboxListServiceTest.php @@ -0,0 +1,69 @@ +lookup = new FakeArchiveLookup(); + // username => [mailbox setting, domain default] + $this->lookup->mailboxes = [ + 'own@firma.de' => ['1', 0], + 'inherit@firma.de' => ['inherit', 1], + 'excluded@firma.de' => ['0', 1], + 'plain@other.example' => ['inherit', 0], + ]; + } + + private function service(bool $enabled, ?Context $context = null): MailboxListService + { + $settings = new Settings(); + $settings->enabled = $enabled; + $repo = $this->createStub(SettingsRepository::class); + $repo->method('load')->willReturn($settings); + return new MailboxListService($this->lookup, $repo, $context ?? FakeContext::admin()); + } + + public function testArchivedMailboxesWithTheirSource(): void + { + $expected = ['enabled' => 1, 'mailboxes' => [ + ['username' => 'own@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'mailbox'], + ['username' => 'inherit@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'domain'], + ]]; + + $this->assertSame($expected, $this->service(true)->archived()); + // read-only API keys too: archivecow keeps its mailboxes in line with this list + $this->assertSame($expected, $this->service(true, new Context('admin', 'API', [], false, 'ro'))->archived()); + } + + public function testEmptyWhileArchivingIsOff(): void + { + $this->assertSame(['enabled' => 0, 'mailboxes' => []], $this->service(false)->archived()); + } + + public function testOnlyAdmins(): void + { + foreach ([FakeContext::domainAdmin(['own@firma.de'], ['firma.de'], ['archive' => '1']), FakeContext::user('own@firma.de')] as $context) { + try { + $this->service(true, $context)->archived(); + $this->fail('AccessDenied expected'); + } catch (AccessDenied) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Archive/Service/OwnerResolverTest.php b/tests/php/Archive/Service/OwnerResolverTest.php new file mode 100644 index 000000000..baffcd541 --- /dev/null +++ b/tests/php/Archive/Service/OwnerResolverTest.php @@ -0,0 +1,158 @@ +lookup = new FakeArchiveLookup(); + // archived domain firma.de, domain without archiving other.de + $this->lookup->mailboxes = [ + 'anna@firma.de' => ['inherit', 1], + 'bernd@firma.de' => ['inherit', 1], + 'buchhaltung@firma.de' => ['inherit', 1], + 'br@firma.de' => ['0', 1], + 'olga@other.de' => ['inherit', 0], + 'otto@other.de' => ['1', 0], + ]; + // mailboxes have alias self rows + foreach (array_keys($this->lookup->mailboxes) as $username) { + $this->lookup->aliases[$username] = $username; + } + } + + private function owners(array $rcpts, string $from = 'sender@extern.example', string $user = '', bool $bcc = true): array + { + return (new OwnerResolver($this->lookup))->resolve($from, $rcpts, $user, $bcc)['owners']; + } + + public static function incoming(): array + { + return [ + 'mailbox' => [['anna@firma.de'], ['anna@firma.de']], + 'upper case and brackets' => [[''], ['anna@firma.de']], + 'tag' => [['anna+news@firma.de'], ['anna@firma.de']], + 'external' => [['someone@extern.example'], []], + 'mailbox setting off' => [['br@firma.de'], []], + 'domain without archiving' => [['olga@other.de'], []], + 'mailbox setting on in a domain without archiving' => [['otto@other.de'], ['otto@other.de']], + 'several recipients, sorted and unique' => [['bernd@firma.de', 'anna@firma.de', 'anna+x@firma.de'], ['anna@firma.de', 'bernd@firma.de']], + ]; + } + + #[DataProvider('incoming')] + public function testIncomingRecipients(array $rcpts, array $owners): void + { + $this->assertSame($owners, $this->owners($rcpts)); + } + + public function testAliasExpandsAllBranches(): void + { + $this->lookup->aliases['team@firma.de'] = 'sales@firma.de,support@firma.de'; + $this->lookup->aliases['sales@firma.de'] = 'anna@firma.de'; + $this->lookup->aliases['support@firma.de'] = 'bernd@firma.de,someone@extern.example'; + + $this->assertSame(['anna@firma.de', 'bernd@firma.de'], $this->owners(['team@firma.de'])); + } + + public function testAliasKeepingACopyAndLoops(): void + { + $this->lookup->aliases['anna@firma.de'] = 'anna@firma.de,bernd@firma.de'; + $this->lookup->aliases['loop1@firma.de'] = 'loop2@firma.de'; + $this->lookup->aliases['loop2@firma.de'] = 'loop1@firma.de,olga@other.de,otto@other.de'; + + $this->assertSame(['anna@firma.de', 'bernd@firma.de'], $this->owners(['anna@firma.de'])); + $this->assertSame(['otto@other.de'], $this->owners(['loop1@firma.de'])); + } + + public function testCatchAllOnlyForAddressesWithoutOwnEntry(): void + { + $this->lookup->aliases['@firma.de'] = 'buchhaltung@firma.de'; + $this->lookup->aliases['old@firma.de'] = 'gone@firma.de'; + + $this->assertSame(['buchhaltung@firma.de'], $this->owners(['unknown@firma.de'])); + $this->assertSame(['anna@firma.de'], $this->owners(['anna@firma.de'])); + // a target without entry of its own gets the catch-all too, like Postfix + $this->assertSame(['buchhaltung@firma.de'], $this->owners(['old@firma.de'])); + } + + public function testAliasDomain(): void + { + $this->lookup->aliasDomains['firma.com'] = 'firma.de'; + $this->lookup->aliases['@firma.com'] = 'bernd@firma.de'; + + $this->assertSame(['anna@firma.de'], $this->owners(['anna@firma.com'])); + $this->assertSame(['anna@firma.de'], $this->owners(['anna+tag@firma.com'])); + $this->assertSame(['bernd@firma.de'], $this->owners(['nobody@firma.com'])); + } + + public function testRecipientMapsRewriteFirst(): void + { + $this->lookup->recipientMaps['anna@extern.example'] = 'anna@firma.de'; + $this->lookup->recipientMaps['@alt.example'] = '@firma.de'; + + $this->assertSame(['anna@firma.de'], $this->owners(['anna@extern.example'])); + $this->assertSame(['bernd@firma.de'], $this->owners(['bernd@alt.example'])); + } + + public function testSpamAliasResourceAndLocalTargets(): void + { + $this->lookup->spamAliases['x7k2@firma.de'] = 'anna@firma.de'; + $this->lookup->resources = ['room@firma.de']; + $this->lookup->aliases['@firma.de'] = 'bernd@firma.de'; + $this->lookup->aliases['learn@firma.de'] = 'spam@localhost'; + + $this->assertSame(['anna@firma.de'], $this->owners(['x7k2@firma.de'])); + $this->assertSame([], $this->owners(['room@firma.de'])); + $this->assertSame([], $this->owners(['learn@firma.de'])); + } + + public function testBccDestinationsOnlyWhenRspamdSentTheCopy(): void + { + $this->lookup->bcc['rcpt']['olga@other.de'] = 'buchhaltung@firma.de'; + $this->lookup->bcc['sender']['@extern.example'] = 'bernd@firma.de'; + + $this->assertSame(['bernd@firma.de', 'buchhaltung@firma.de'], $this->owners(['olga+tag@other.de'])); + $this->assertSame([], $this->owners(['olga@other.de'], bcc: false)); + } + + public function testOutgoingSaslUserAndSentAsMailbox(): void + { + $this->lookup->aliases['info@firma.de'] = 'anna@firma.de,bernd@firma.de'; + $this->lookup->aliasDomains['firma.com'] = 'firma.de'; + $ext = ['someone@extern.example']; + + $this->assertSame(['anna@firma.de'], $this->owners($ext, 'anna@firma.de', 'anna@firma.de')); + $this->assertSame(['anna@firma.de', 'buchhaltung@firma.de'], $this->owners($ext, 'buchhaltung@firma.de', 'anna@firma.de')); + $this->assertSame(['anna@firma.de', 'buchhaltung@firma.de'], $this->owners($ext, 'buchhaltung+x@firma.com', 'Anna@firma.de')); + // an alias as sender is no mailbox + $this->assertSame(['anna@firma.de'], $this->owners($ext, 'info@firma.de', 'anna@firma.de')); + // the sent-as mailbox has archiving off + $this->assertSame(['anna@firma.de'], $this->owners($ext, 'br@firma.de', 'anna@firma.de')); + } + + public function testSenderWithoutLoginNeverOwns(): void + { + $this->assertSame([], $this->owners(['someone@extern.example'], 'anna@firma.de')); + $this->assertSame([], $this->owners(['someone@extern.example'], 'anna@firma.de', 'x1@mailcow.local')); + } + + public function testDirection(): void + { + $resolver = new OwnerResolver($this->lookup); + + $this->assertSame('in', $resolver->resolve('x@extern.example', ['anna@firma.de'], '', false)['direction']); + $this->assertSame('out', $resolver->resolve('anna@firma.de', ['x@extern.example'], 'anna@firma.de', false)['direction']); + $this->assertSame('internal', $resolver->resolve('anna@firma.de', ['olga@other.de'], 'anna@firma.de', false)['direction']); + } +} diff --git a/tests/php/Archive/Service/SettingsServiceTest.php b/tests/php/Archive/Service/SettingsServiceTest.php new file mode 100644 index 000000000..2842d0ccb --- /dev/null +++ b/tests/php/Archive/Service/SettingsServiceTest.php @@ -0,0 +1,189 @@ +stored = new Settings(); + $this->stored->address = 'journal@archive.example'; + $this->stored->password = 'old-password'; + $this->stored->signingKey = 'old-key'; + $this->repo = $this->createMock(SettingsRepository::class); + $this->repo->method('load')->willReturnCallback(fn(): Settings => clone $this->stored); + $this->redis = new FakeRedis(); + $this->smtp = new FakeSmtpClient(); + $this->lookup = new FakeArchiveLookup(); + $this->lookup->domains = ['firma.de', 'alias-firma.de']; + } + + private function service(?Context $context = null): SettingsService + { + return new SettingsService($this->repo, $this->lookup, new ArchiveState($this->redis), $this->smtp, $context ?? FakeContext::admin()); + } + + 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 testSaveKeepsMissingValuesAndEmptySecrets(): void + { + $this->repo->expects($this->once())->method('save')->with($this->callback(fn(Settings $s): bool => $s->enabled + && $s->address === 'journal@archive.example' && $s->password === 'old-password' && $s->signingKey === 'old-key')); + + $settings = $this->service()->save(['enabled' => ['0', '1'], 'password' => '', 'signing_secret' => '']); + + $this->assertSame(1, $settings->toArray()['enabled']); + $this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']); + } + + public function testSaveRemovesThePassword(): void + { + $this->assertSame('old-password', $this->service()->save(['password' => '', 'password_remove' => '0'])->password); + $this->assertSame('', $this->service()->save(['password' => '', 'password_remove' => '1'])->password); + } + + public function testEnabledIsOffWithoutTheTable(): void + { + $repo = $this->createMock(SettingsRepository::class); + $repo->method('load')->willThrowException(new PDOException('no such table')); + + $this->assertFalse((new SettingsService($repo, $this->lookup, new ArchiveState($this->redis), $this->smtp, FakeContext::admin()))->enabled()); + } + + public function testAddressOutsideTheMailcowDomains(): void + { + $this->expectUserError('archive_address_local', fn() => $this->service()->save(['address' => 'journal@firma.de'])); + $this->expectUserError('archive_address_local', fn() => $this->service()->save(['address' => 'Journal@Alias-Firma.de'])); + $this->assertSame('journal@archiv.firma.de', $this->service()->save(['address' => 'journal@archiv.firma.de'])->address); + } + + public function testSaveAllFields(): void + { + $settings = $this->service()->save(['enabled' => '0', 'address' => ' Journal@Archive.Example ', 'host' => '[2001:DB8::25]', + 'port' => '2525', 'tls' => 'verify', 'username' => 'mailcow', 'password' => 'new:password', 'profile' => 'envelope', + 'signing_secret' => 'new-key', 'header_from' => 'X-Real-MailFrom', 'header_rcpt' => 'X-Real-RcptTo']); + + $this->assertSame(['enabled' => 0, 'address' => 'journal@archive.example', 'host' => '2001:db8::25', 'port' => 2525, + 'tls' => 'verify', 'username' => 'mailcow', 'has_password' => 1, 'profile' => 'envelope', 'has_signing_secret' => 1, + 'header_from' => 'X-Real-MailFrom', 'header_rcpt' => 'X-Real-RcptTo'], $settings->toArray()); + $this->assertSame(['new:password', 'new-key'], [$settings->password, $settings->signingKey]); + $this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']); + } + + public static function invalid(): array + { + return [ + 'address' => [['address' => 'journal'], 'archive_address_invalid'], + 'address array' => [['address' => ['x']], 'archive_address_invalid'], + 'host' => [['host' => 'archive example'], 'archive_host_invalid'], + 'port' => [['port' => '0'], 'archive_port_invalid'], + 'port too high' => [['port' => '65536'], 'archive_port_invalid'], + 'tls' => [['tls' => 'none'], 'archive_tls_invalid'], + 'username with colon' => [['username' => 'a:b'], 'archive_username_invalid'], + 'password with space' => [['password' => 'a b'], 'archive_password_invalid'], + 'password too long' => [['password' => str_repeat('a', 256)], 'archive_password_invalid'], + 'profile' => [['profile' => 'journal'], 'archive_profile_invalid'], + 'signing secret not ascii' => [['signing_secret' => 'schlüssel'], 'archive_signing_secret_invalid'], + 'header name with space' => [['header_from' => 'X Envelope'], 'archive_header_invalid'], + 'header name with line break' => [['header_rcpt' => "X-To\r\nBcc"], 'archive_header_invalid'], + 'switch on without address' => [['enabled' => '1', 'address' => ''], 'archive_incomplete'], + ]; + } + + #[DataProvider('invalid')] + public function testSaveValidates(array $input, string $error): void + { + $this->repo->expects($this->never())->method('save'); + $this->expectUserError($error, fn() => $this->service()->save($input)); + $this->assertArrayNotHasKey('ARCHIVE_ENABLED', $this->redis->strings); + } + + public function testArchivecowNeedsASigningKey(): void + { + $this->stored->signingKey = ''; + $this->expectUserError('archive_incomplete', fn() => $this->service()->save(['enabled' => '1'])); + + $this->assertTrue($this->service()->save(['enabled' => '1', 'profile' => 'none'])->enabled); + } + + public function testPermissions(): void + { + $readOnly = new Context('admin', 'API', [], false, 'ro'); + $this->repo->expects($this->never())->method('save'); + + $this->assertSame(1, $this->service($readOnly)->get()->toArray()['has_password']); + $this->expectUserError('access_denied', fn() => $this->service($readOnly)->save(['enabled' => '0'])); + $this->expectUserError('access_denied', fn() => $this->service($readOnly)->test()); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->get()); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user())->save([])); + $this->assertSame([], $this->smtp->probed); + } + + public function testConnectionTestWithTheStoredTarget(): void + { + $this->stored->host = 'smtp.archive.example'; + $this->stored->port = 587; + $this->stored->tls = 'verify'; + $this->stored->username = 'mailcow'; + + $this->assertSame('archive_test_ok', $this->service()->test()); + $this->assertSame([['host' => 'smtp.archive.example', 'port' => 587, 'tls' => 'verify', 'username' => 'mailcow', + 'password' => 'old-password', 'from' => 'archive-bounce@localhost', 'rcpt' => 'journal@archive.example']], $this->smtp->probed); + } + + public function testConnectionTestReportsMissingExtensions(): void + { + $this->smtp->mx['archive.example'] = 'mx1.archive.example'; + $this->smtp->extensions = ['STARTTLS', 'SIZE']; + + $this->assertSame(['archive_test_ok_missing', '8BITMIME'], $this->service()->test()); + $this->assertSame('mx1.archive.example', $this->smtp->probed[0]['host']); + } + + public function testConnectionTestFailure(): void + { + $this->smtp->error = new SmtpException('recipient', "550 5.1.1 :\n unknown"); + + $this->expectUserError(['archive_test_failed', 'archive.example:25 recipient: 550 5.1.1 journal@archive.example: unknown'], + fn() => $this->service()->test()); + } + + public function testConnectionTestNeedsAnAddress(): void + { + $this->stored->address = ''; + $this->expectUserError('archive_incomplete', fn() => $this->service()->test()); + $this->assertSame([], $this->smtp->probed); + } +} diff --git a/tests/php/Archive/Service/StatusServiceTest.php b/tests/php/Archive/Service/StatusServiceTest.php new file mode 100644 index 000000000..0753b8f10 --- /dev/null +++ b/tests/php/Archive/Service/StatusServiceTest.php @@ -0,0 +1,115 @@ +redis = new FakeRedis(); + } + + private function service(?Context $context = null): StatusService + { + $settings = new Settings(); + $settings->enabled = true; + $settings->address = 'journal@archive.example'; + $settings->profile = 'none'; + $repo = $this->createStub(SettingsRepository::class); + $repo->method('load')->willReturn($settings); + $queue = $this->createMock(LegacyQueue::class); + $queue->method('entries')->with('archive-bounce@localhost')->willReturn([ + ['queue_id' => 'A1', 'arrival_time' => 1700000100, 'message_size' => 2048, 'recipients' => ['journal@archive.example (' . self::REFUSED . ')']], + ['queue_id' => 'A2', 'arrival_time' => 1700000000, 'message_size' => 1024, 'recipients' => ['journal@archive.example (' . self::REFUSED . ')']], + ['queue_id' => 'A3', 'arrival_time' => 1700000200, 'message_size' => 10, 'recipients' => ['journal@archive.example']], + ]); + return new StatusService($repo, new ArchiveState($this->redis), $queue, $context ?? new Context('admin', 'API', [], false, 'ro')); + } + + /** POSTFIX_MAILLOG line as syslog-ng writes it */ + private static function log(int $time, string $program, string $message): string + { + return json_encode(['time' => (string)$time, 'program' => $program, 'priority' => 'info', 'message' => $message]); + } + + public function testStatus(): void + { + $state = new ArchiveState($this->redis); + // a day old: no longer a problem, still counted + $this->redis->lPush('ARCHIVE_DROPPED_LOG', json_encode(['time' => time() - 90000, 'qid' => 'OLD1', 'reason' => 'old', 'message_id' => ''])); + $state->dropped('4XyZ1', 'owner lookup failed', 'm1@firma.de'); + $this->redis->lists['POSTFIX_MAILLOG'] = [ + self::log(1700000300, 'postfix/archive/smtp', 'A4: to=, relay=archive.example[192.0.2.5]:25, delay=0.2, ' + . 'delays=0.1/0/0.05/0.05, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 77)'), + self::log(1700000280, 'postfix/archive/smtp', 'A1: to=, relay=none, delay=180, dsn=4.4.1, status=deferred (' . self::REFUSED . ')'), + self::log(1700000200, 'postfix/archive/smtp', 'A0: to=, relay=archive.example[192.0.2.5]:25, dsn=2.0.0, status=sent (250 ok)'), + ]; + + $this->assertSame([ + 'enabled' => 1, 'configured' => 1, 'queued' => 3, 'queued_bytes' => 3082, 'oldest_queued' => 1700000000, + 'delay_reasons' => [self::REFUSED], 'last_delivery' => 1700000300, 'dropped' => 1, 'dropped_24h' => 1, + 'last_dropped' => ['time' => $this->redis->lists['ARCHIVE_DROPPED_LOG'] ? json_decode($this->redis->lists['ARCHIVE_DROPPED_LOG'][0], true)['time'] : 0, + 'reason' => 'owner lookup failed'], + ], $this->service()->status()); + } + + public function testLogWithMessageIds(): void + { + (new ArchiveState($this->redis))->dropped('4XyZ1', 'owner lookup failed', 'm1@firma.de'); + $this->redis->lists['POSTFIX_MAILLOG'] = [ + self::log(1700000310, 'postfix/qmgr', 'E1: from=, status=expired, returned to sender'), + self::log(1700000300, 'postfix/archive/smtp', 'A4: to=, relay=archive.example[192.0.2.5]:25, dsn=2.0.0, ' + . 'status=sent (250 2.0.0 Ok: queued as 77)'), + self::log(1700000295, 'postfix/qmgr', 'Q9: from=, status=expired, returned to sender'), + self::log(1700000290, 'postfix/smtp', 'B1: to=, relay=mx.extern.example[198.51.100.7]:25, dsn=2.0.0, status=sent (250 Ok)'), + self::log(1700000285, 'postfix/archive/discard', 'D1: to=, relay=none, dsn=2.0.0, status=sent (localhost)'), + self::log(1700000280, 'postfix/archive/smtp', 'A2: to=, relay=archive.example[192.0.2.5]:25, dsn=4.1.1, ' + . 'status=SOFTBOUNCE (host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command))'), + self::log(1700000270, 'postfix/archive/smtp', 'Untrusted TLS connection established to archive.example[192.0.2.5]:25'), + self::log(1700000262, 'postfix/cleanup', 'A4: message-id='), + self::log(1700000261, 'postfix/cleanup', 'A2: message-id='), + 'not json', + ]; + + $log = $this->service()->log(); + + // the mail not archived just now is the newest entry + $this->assertSame(['status' => 'dropped', 'qid' => '4XyZ1', 'message_id' => 'm1@firma.de', 'text' => 'owner lookup failed'], + array_diff_key($log[0], ['time' => 0])); + $this->assertSame([ + ['time' => 1700000310, 'status' => 'expired', 'qid' => 'E1', 'message_id' => '', 'text' => ''], + ['time' => 1700000300, 'status' => 'sent', 'qid' => 'A4', 'message_id' => 'm4@extern.example', 'text' => '250 2.0.0 Ok: queued as 77'], + ['time' => 1700000280, 'status' => 'softbounce', 'qid' => 'A2', 'message_id' => 'm2@firma.de', + 'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'], + ], array_slice($log, 1)); + } + + public function testOnlyAdmins(): void + { + foreach (['status', 'log'] as $method) { + try { + $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->$method(); + $this->fail('UserError expected'); + } catch (UserError) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Archive/Smtp/SmtpClientTest.php b/tests/php/Archive/Smtp/SmtpClientTest.php new file mode 100644 index 000000000..8ff710eaa --- /dev/null +++ b/tests/php/Archive/Smtp/SmtpClientTest.php @@ -0,0 +1,123 @@ +written[] = "OPEN $host:$port"; + } + + protected function close(): void + { + $this->closed = true; + } + + protected function write(string $data): void + { + $this->written[] = $data; + } + + protected function readLine(): ?string + { + return array_shift($this->replies); + } + + protected function startTls(string $host, bool $verify): void + { + $this->tls[] = [$host, $verify]; + } + }; + } + + public function testSubmitWithCrlfAndDotStuffing(): void + { + $client = self::client(['220 postfix ESMTP', '250-postfix', '250 8BITMIME', '250 2.1.0 Ok', '250 2.1.5 Ok', + '354 End data with .', '250 2.0.0 Ok: queued as 4XyZ1']); + + $qid = $client->submit('postfix', 591, 'archive-bounce@localhost', 'journal@archive.example', "X-A: 1\r\n\r\n.dot\nline\r\n"); + + $this->assertSame('4XyZ1', $qid); + $this->assertSame(['OPEN postfix:591', 'EHLO ' . (gethostname() ?: 'mailcow') . "\r\n", "MAIL FROM:\r\n", + "RCPT TO:\r\n", "DATA\r\n", "X-A: 1\r\n\r\n..dot\r\nline\r\n.\r\n", "QUIT\r\n"], $client->written); + $this->assertTrue($client->closed); + } + + public function testProbeWithStartTlsAndAuth(): void + { + $client = self::client(['220 archive ESMTP', '250-archive', '250-STARTTLS', '250 AUTH PLAIN', '220 2.0.0 Ready', + '250-archive', '250-8BITMIME', '250-SMTPUTF8', '250 AUTH PLAIN', '235 2.7.0 Authenticated', '250 Ok', '250 Ok', '250 Ok']); + + $extensions = $client->probe('archive.example', 25, 'verify', 'user', 'pw', 'archive-bounce@localhost', 'journal@archive.example'); + + $this->assertSame(['8BITMIME', 'SMTPUTF8', 'AUTH'], $extensions); + $this->assertSame([['archive.example', true]], $client->tls); + $this->assertContains('AUTH PLAIN ' . base64_encode("\0user\0pw") . "\r\n", $client->written); + $this->assertContains("RSET\r\n", $client->written); + } + + public function testProbeNeedsStartTlsUnlessOptional(): void + { + try { + self::client(['220 archive ESMTP', '250 archive'])->probe('archive.example', 25, 'encrypt', '', '', 'a@localhost', 'j@a.example'); + $this->fail('SmtpException expected'); + } catch (SmtpException $e) { + $this->assertSame('tls: STARTTLS not offered', $e->describe()); + } + $client = self::client(['220 archive ESMTP', '250 archive', '250 Ok', '250 Ok', '250 Ok']); + $this->assertSame([], $client->probe('archive.example', 25, 'may', '', '', 'a@localhost', 'j@a.example')); + $this->assertSame([], $client->tls); + } + + public function testRefusalsNameTheStep(): void + { + $cases = [ + 'connect' => ['421 4.3.2 Service not available'], + 'auth' => ['220 archive', '250-archive', '250 STARTTLS', '220 Go', '250 archive', '535 5.7.8 failed'], + 'recipient' => ['220 archive', '250-archive', '250 STARTTLS', '220 Go', '250 archive', '235 Ok', '250 Ok', '550 5.1.1 : unknown'], + ]; + foreach ($cases as $step => $replies) { + try { + self::client($replies)->probe('archive.example', 25, 'encrypt', 'user', 'pw', 'a@localhost', 'j@a.example'); + $this->fail("SmtpException at $step expected"); + } catch (SmtpException $e) { + $this->assertSame($step, $e->step); + $this->assertSame(end($replies), $e->reply); + } + } + } + + public function testConnectionClosedDuringData(): void + { + $this->expectException(SmtpException::class); + self::client(['220 postfix', '250 postfix', '250 Ok', '250 Ok', '354 go'])->submit('postfix', 591, 'a@localhost', 'j@a.example', "x\r\n"); + } + + public function testCrlfAndDotStuffing(): void + { + $this->assertSame("a\r\nb\r\nc\r\n", SmtpClient::crlf("a\nb\rc")); + $this->assertSame("a\r\n", SmtpClient::crlf("a\r\n")); + $this->assertSame("..a\r\n..\r\n", SmtpClient::dotStuff(".a\r\n.\r\n")); + } +} diff --git a/tests/php/Support/FakeArchiveLookup.php b/tests/php/Support/FakeArchiveLookup.php new file mode 100644 index 000000000..8985adb18 --- /dev/null +++ b/tests/php/Support/FakeArchiveLookup.php @@ -0,0 +1,87 @@ + [archive setting, domain archive]. + */ +class FakeArchiveLookup extends LookupRepository +{ + public array $recipientMaps = []; + public array $aliases = []; + public array $resources = []; + public array $spamAliases = []; + /** alias domain => target domain */ + public array $aliasDomains = []; + public array $mailboxes = []; + /** ['rcpt' => [key => dest], 'sender' => [key => dest]] */ + public array $bcc = ['rcpt' => [], 'sender' => []]; + /** @var string[] domains and alias domains of the mailcow */ + public array $domains = []; + /** @var string[] every lookup as ":" */ + public array $queries = []; + + public function __construct() + { + } + + public function recipientMap(string $key): ?string + { + $this->queries[] = 'recipientMap:' . $key; + return $this->recipientMaps[$key] ?? null; + } + + public function aliasGoto(string $key): ?string + { + $this->queries[] = 'aliasGoto:' . $key; + return $this->aliases[$key] ?? null; + } + + public function isResource(string $username): bool + { + return in_array($username, $this->resources, true); + } + + public function spamAliasGoto(string $key): ?string + { + return $this->spamAliases[$key] ?? null; + } + + public function aliasDomainMailbox(string $local, string $domain): ?string + { + $target = $this->aliasDomains[$domain] ?? null; + return ($target !== null && isset($this->mailboxes[$local . '@' . $target])) ? $local . '@' . $target : null; + } + + public function bccDestination(string $key, string $type): ?string + { + return $this->bcc[$type][$key] ?? null; + } + + public function isMailcowDomain(string $domain): bool + { + return in_array($domain, $this->domains, true); + } + + public function mailbox(string $username): ?Mailbox + { + if (!isset($this->mailboxes[$username])) { + return null; + } + [$archive, $domainArchive] = $this->mailboxes[$username]; + return Mailbox::fromRow(['username' => $username, 'domain' => substr($username, strrpos($username, '@') + 1), + 'archive' => $archive, 'domain_archive' => $domainArchive, 'name' => '', 'active' => 1]); + } + + public function archivedMailboxes(): array + { + return array_values(array_filter(array_map(fn(string $username): Mailbox => $this->mailbox($username), array_keys($this->mailboxes)), + fn(Mailbox $mailbox): bool => $mailbox->archived())); + } +} diff --git a/tests/php/Support/FakeRedis.php b/tests/php/Support/FakeRedis.php new file mode 100644 index 000000000..ad5a93500 --- /dev/null +++ b/tests/php/Support/FakeRedis.php @@ -0,0 +1,54 @@ +strings[$key] = $value; + return true; + } + + public function get(string $key): string|false + { + return $this->strings[$key] ?? false; + } + + public function incr(string $key): int + { + $this->strings[$key] = (string)((int)($this->strings[$key] ?? 0) + 1); + return (int)$this->strings[$key]; + } + + public function lPush(string $key, string $value): int + { + $this->lists[$key] ??= []; + array_unshift($this->lists[$key], $value); + return count($this->lists[$key]); + } + + public function lTrim(string $key, int $start, int $stop): bool + { + $this->lists[$key] = array_slice($this->lists[$key] ?? [], $start, $stop - $start + 1); + return true; + } + + /** Like Redis, a negative stop counts from the end (-1 is the last element) */ + public function lRange(string $key, int $start, int $stop): array + { + $list = $this->lists[$key] ?? []; + if ($stop < 0) { + $stop += count($list); + } + return array_slice($list, $start, max(0, $stop - $start + 1)); + } +} diff --git a/tests/php/Support/FakeSmtpClient.php b/tests/php/Support/FakeSmtpClient.php new file mode 100644 index 000000000..95bc225d1 --- /dev/null +++ b/tests/php/Support/FakeSmtpClient.php @@ -0,0 +1,46 @@ +error !== null) { + throw $this->error; + } + $this->submitted[] = compact('host', 'port', 'from', 'rcpt', 'data'); + return 'Q' . count($this->submitted); + } + + public function probe(string $host, int $port, string $tls, string $username, string $password, string $from, string $rcpt): array + { + $this->probed[] = compact('host', 'port', 'tls', 'username', 'password', 'from', 'rcpt'); + if ($this->error !== null) { + throw $this->error; + } + return $this->extensions; + } + + public function mailHost(string $domain): string + { + return $this->mx[$domain] ?? $domain; + } +}