mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-07 09:19:21 +00:00
[Web][Tests] Add archive module tests
This commit is contained in:
14 files changed
+1403
No files matched your search
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Journal;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Service\HeaderBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HeaderBuilderTest extends TestCase
|
||||
{
|
||||
private static function settings(string $profile): Settings
|
||||
{
|
||||
$settings = new Settings();
|
||||
$settings->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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\HeaderBuilder;
|
||||
use Mailcow\Archive\Service\JournalService;
|
||||
use Mailcow\Archive\Service\OwnerResolver;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use Mailcow\Tests\Support\FakeSmtpClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class JournalServiceTest extends TestCase
|
||||
{
|
||||
private Settings $stored;
|
||||
private FakeArchiveLookup $lookup;
|
||||
private FakeRedis $redis;
|
||||
private FakeSmtpClient $smtp;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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: <mm1@firma.de>\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 <journal@archive.example>: 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\MailboxListService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MailboxListServiceTest extends TestCase
|
||||
{
|
||||
private FakeArchiveLookup $lookup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Service\OwnerResolver;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class OwnerResolverTest extends TestCase
|
||||
{
|
||||
private FakeArchiveLookup $lookup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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>'], ['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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\SettingsService;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use Mailcow\Tests\Support\FakeSmtpClient;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SettingsServiceTest extends TestCase
|
||||
{
|
||||
private SettingsRepository&MockObject $repo;
|
||||
private Settings $stored;
|
||||
private FakeRedis $redis;
|
||||
private FakeSmtpClient $smtp;
|
||||
private FakeArchiveLookup $lookup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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 <journal@archive.example>:\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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Legacy\LegacyQueue;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\StatusService;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StatusServiceTest extends TestCase
|
||||
{
|
||||
private const REFUSED = 'connect to archive.example[192.0.2.5]:25: Connection refused';
|
||||
|
||||
private FakeRedis $redis;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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=<journal@archive.example>, 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=<journal@archive.example>, relay=none, delay=180, dsn=4.4.1, status=deferred (' . self::REFUSED . ')'),
|
||||
self::log(1700000200, 'postfix/archive/smtp', 'A0: to=<journal@archive.example>, 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=<archive-bounce@localhost>, status=expired, returned to sender'),
|
||||
self::log(1700000300, 'postfix/archive/smtp', 'A4: to=<journal@archive.example>, 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=<someone@firma.de>, status=expired, returned to sender'),
|
||||
self::log(1700000290, 'postfix/smtp', 'B1: to=<x@extern.example>, 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=<archive-bounce@localhost>, relay=none, dsn=2.0.0, status=sent (localhost)'),
|
||||
self::log(1700000280, 'postfix/archive/smtp', 'A2: to=<journal@archive.example>, 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=<m4@extern.example>'),
|
||||
self::log(1700000261, 'postfix/cleanup', 'A2: message-id=<m2@firma.de>'),
|
||||
'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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user