[Web][Restic][Dovecot][Dockerapi] Add restic backups with restore in the UI

- Scheduled, encrypted backups to S3, SFTP, REST or a local folder (new restic-mailcow container)
- Restore of mailboxes and whole domains in the UI, also deleted ones, for admins and domain admins
- Scope set by RESTIC_BACKUP_MODE in mailcow.conf (all, data, config)
- Optional backup before updates (update.sh), helper-scripts/restic.sh for a full restore
This commit is contained in:
FreddleSpl0it committed 2026-11-08 22:56:27 +01:00
1 parent a64ee13670
commit 6cf0390d58
66 files changed
+15214 -97

No files matched your search

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Controller;
use Mailcow\Backup\Controller\BackupApiController;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class BackupApiControllerTest extends TestCase
{
private SettingsService&MockObject $settings;
private JobService&MockObject $jobs;
private RestoreService&MockObject $restore;
private DomainRestoreService&MockObject $domainRestore;
private BackupApiController $api;
protected function setUp(): void
{
$_SESSION = [];
$this->settings = $this->createMock(SettingsService::class);
$this->jobs = $this->createMock(JobService::class);
$this->jobs->method('view')->willReturnCallback(fn(Job $job, bool $withLog = false): array => $job->toArray($withLog));
$this->restore = $this->createMock(RestoreService::class);
$this->domainRestore = $this->createMock(DomainRestoreService::class);
$this->api = new BackupApiController($this->settings, $this->jobs, $this->createMock(ExportService::class), $this->restore,
$this->domainRestore, new ApiResponse(Settings::SECRETS));
}
private static function job(int $id, string $type): Job
{
$job = new Job($type);
$job->id = $id;
$job->params = ($type === 'restore') ? ['mailboxes' => ['a@firma.de', 'b@firma.de']] : [];
return $job;
}
public function testSecretsAreMaskedInTheLog(): void
{
$this->settings->expects($this->once())->method('save');
$this->assertTrue($this->api->editSettings(['password' => 'repo-pw', 's3_secret_key' => 'S3', 'rest_password' => '', 'keep_daily' => '9']));
$this->assertSame([['type' => 'success', 'log' => ['backup', 'edit', 'settings', ['password' => '*', 's3_secret_key' => '*', 'rest_password' => '',
'keep_daily' => '9']], 'msg' => 'backup_settings_saved']], $_SESSION['return']);
}
public function testJobMessages(): void
{
$this->jobs->method('create')->willReturnOnConsecutiveCalls(self::job(4, 'backup'), $this->throwException(new UserError('backup_job_active', ['backup'])),
$this->throwException(new PDOException('gone away')));
$this->jobs->method('restore')->willReturn(self::job(5, 'restore'));
$this->jobs->method('cancel')->willReturnCallback(fn(int $id): Job => ($id === 4) ? self::job(4, 'backup') : throw new UserError('backup_job_not_cancellable', [$id]));
$this->assertTrue($this->api->addJob(['type' => 'backup']));
$this->assertFalse($this->api->addJob(['type' => 'backup']));
$this->assertFalse($this->api->addJob(['type' => 'backup']));
$this->assertTrue($this->api->addRestore(['snapshot' => 'abc']));
$this->assertTrue($this->api->deleteJobs([4, '7', 'x']));
$this->assertSame([
['success', ['backup_job_added', 'backup', 4]],
['danger', ['backup_job_active', 'backup']],
['danger', ['mysql_error', 'gone away']],
['success', ['backup_restore_added', 5, 2]],
['success', ['backup_job_cancelled', 4]],
['danger', ['backup_job_not_cancellable', 7]],
['danger', 'access_denied'],
], array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return']));
$this->assertSame(['backup', 'delete', 'job', ['id' => [4, '7', 'x']]], $_SESSION['return'][4]['log']);
}
public function testReads(): void
{
$this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => ($id === 4) ? self::job(4, 'backup') : null);
$this->jobs->method('list')->willReturn([self::job(4, 'backup')]);
$this->assertSame([4, ''], [$this->api->getJob('4')['id'], $this->api->getJob('4')['log']]);
$this->assertSame([], $this->api->getJob('x'));
$this->assertSame([], $this->api->getJob(5));
$this->assertArrayNotHasKey('log', $this->api->getJobs()[0]);
$this->assertSame([], $this->api->getSettings());
}
public function testTheRunnersActions(): void
{
$this->restore->expects($this->once())->method('merge')->with(12, 'a@firma.de', ['mailbox' => []])
->willReturn(['bcc' => ['added' => 1, 'skipped' => 0, 'errors' => []]]);
$this->restore->expects($this->once())->method('mergeAliases')->with(12, [['address' => 'x@firma.de']])
->willReturn(['aliases' => ['added' => 1, 'skipped' => 0, 'errors' => []]]);
$this->domainRestore->expects($this->once())->method('restore')->with(12, ['domain' => ['domain' => 'firma.de']])
->willReturn(['domain' => ['added' => 1, 'skipped' => 0, 'errors' => [], 'created' => 1]]);
// the components come from the job, never from the request
$this->assertSame(['result' => ['bcc' => ['added' => 1, 'skipped' => 0, 'errors' => []]]],
$this->api->post(['action' => 'restore', 'job' => 12, 'username' => 'a@firma.de', 'data' => ['mailbox' => []], 'components' => ['password']]));
$this->assertSame(['result' => ['aliases' => ['added' => 1, 'skipped' => 0, 'errors' => []]]],
$this->api->post(['action' => 'restore_aliases', 'job' => 12, 'aliases' => [['address' => 'x@firma.de']]]));
$this->assertSame(1, $this->api->post(['action' => 'restore_domain', 'job' => 12, 'data' => ['domain' => ['domain' => 'firma.de']]])['result']['domain']['created']);
try {
$this->api->post(['action' => 'drop_everything']);
$this->fail('UserError expected');
} catch (UserError $e) {
$this->assertSame('backup_action_invalid', $e->key);
}
}
public function testSearchAndJobsOfOneType(): void
{
$this->jobs->method('search')->with('abc', 'anna@')->willReturn(['domains' => [], 'truncated' => 0]);
$this->jobs->expects($this->once())->method('list')->with(0, 'restore')->willReturn([self::job(5, 'restore')]);
$this->assertSame(['domains' => [], 'truncated' => 0], $this->api->getSnapshotSearch('abc', 'anna@'));
$this->assertSame(['restore'], array_column($this->api->getJobs(0, 'restore'), 'type'));
}
public function testSnapshotContent(): void
{
$this->jobs->method('content')->with('abc')->willReturn(['snapshot' => 'abc']);
$this->jobs->method('domainContent')->with('abc', 'firma.de')->willReturn(['domain' => 'firma.de']);
$this->assertSame(['snapshot' => 'abc'], $this->api->getSnapshotContent('abc', null));
$this->assertSame(['domain' => 'firma.de'], $this->api->getSnapshotContent('abc', 'firma.de'));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Legacy;
use Mailcow\Backup\Legacy\NoTemplates;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\TemplateService;
use PHPUnit\Framework\TestCase;
class NoTemplatesTest extends TestCase
{
public function testARestoredMembershipAppliesNoGroupTemplate(): void
{
$lookup = $this->createMock(LookupRepository::class);
// the mailbox gets a group with a template
$lookup->method('effectiveTemplates')->willReturn(['anna@firma.de' => 'Vertrieb']);
$lookup->method('mailboxNames')->willReturn(['anna@firma.de' => 'Anna']);
// the legacy mailbox code is never called (it is not loaded in the tests)
$this->assertSame([], (new TemplateService($lookup, new NoTemplates()))->applyChanged(['anna@firma.de' => null]));
}
}
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Service\DomainRestoreService;
use Mailcow\Backup\Service\GroupMembers;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Backup\Support\FakeRestoreRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class DomainRestoreServiceTest extends TestCase
{
private FakeRestoreRepository $repo;
private JobRepository&MockObject $jobs;
private GroupMembers&MockObject $groups;
private LegacyDomain&MockObject $legacy;
private Job $job;
/** @var array Redis of the legacy code: dkim, ratelimit, domain_map */
private array $redis = ['dkim' => [], 'ratelimit' => [], 'domain_map' => []];
private int $sogoRestarts = 0;
/** @var array[] groups created through the group module: [role, input] */
private array $createdGroups = [];
protected function setUp(): void
{
$this->repo = new FakeRestoreRepository([
'domain' => [['domain' => 'firma.de', 'aliases' => 400, 'mailboxes' => 10, 'quota' => 10240, 'maxquota' => 2048, 'active' => 1]],
'relayhosts' => [['id' => 1, 'hostname' => 'relay.example.org']],
'admin' => [['username' => 'da', 'superadmin' => 0, 'active' => 1], ['username' => 'admin', 'superadmin' => 1, 'active' => 1]],
'sender_acl' => [['id' => 1, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'info@alt.de', 'external' => 1, 'group_id' => null]],
]);
$this->job = new Job('restore');
$this->job->id = 20;
$this->job->state = 'running';
$this->job->domain = 'alt.de';
$this->job->params = ['scope' => 'domain', 'domain' => 'alt.de', 'mailboxes' => ['x@alt.de'], 'components' => ['mails', 'aliases'],
'requested_role' => 'admin', 'create_domain' => 1, 'create_mailboxes' => ['x@alt.de']];
$this->jobs = $this->createMock(JobRepository::class);
$this->jobs->method('find')->willReturnCallback(fn(int $id): ?Job => ($id === 20) ? $this->job : null);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('create')->willReturnCallback(function (Context $context, array $input): int {
if ($input['name'] === 'Broken') {
throw new UserError('group_address_invalid', ['x']);
}
$this->createdGroups[] = [$context->role, $input];
$id = 100 + count($this->createdGroups);
$this->repo->tables['mailbox_group'][] = ['id' => $id, 'domain' => $input['domain'], 'name' => $input['name'], 'address' => $input['address'] ?? null];
return $id;
});
$this->legacy = $this->createMock(LegacyDomain::class);
$this->legacy->method('dkim')->willReturnCallback(fn(string $domain): ?array => $this->redis['dkim'][$domain] ?? null);
$this->legacy->method('addDkim')->willReturnCallback(function (string $domain, string $selector, string $pubkey, string $privkey): void {
$this->redis['dkim'][$domain] = ['selector' => $selector, 'pubkey' => $pubkey, 'privkey' => $privkey];
});
$this->legacy->method('rateLimits')->willReturnCallback(fn(array $objects): array => array_intersect_key($this->redis['ratelimit'], array_flip($objects)));
$this->legacy->method('setRateLimit')->willReturnCallback(function (string $object, string $value): void {
$this->redis['ratelimit'][$object] = $value;
});
$this->legacy->method('addDomainMap')->willReturnCallback(function (string $domain): void {
$this->redis['domain_map'][] = $domain;
});
$this->legacy->method('restartSogo')->willReturnCallback(function (): ?string {
$this->sogoRestarts++;
return null;
});
}
private function service(): DomainRestoreService
{
return new DomainRestoreService($this->repo, $this->jobs, $this->groups, $this->legacy);
}
private static function data(): array
{
return [
'domain' => ['domain' => 'alt.de', 'description' => 'Alt GmbH', 'aliases' => 50, 'mailboxes' => 5, 'defquota' => 1024, 'maxquota' => 2048,
'quota' => 4096, 'relayhost' => '9', 'backupmx' => 0, 'gal' => 1, 'created' => '2020-01-01 00:00:00', 'active' => 1, 'gone_column' => 1],
'tags' => ['kunde', 'kunde', ''],
'alias_domains' => [['alias_domain' => 'alt.com', 'target_domain' => 'alt.de', 'active' => 1],
['alias_domain' => 'firma.de', 'target_domain' => 'alt.de', 'active' => 1]],
'dkim' => [['domain' => 'alt.de', 'selector' => 'dkim', 'pubkey' => 'PUB', 'privkey' => 'PRIV'],
['domain' => 'alt.com', 'selector' => 's2', 'pubkey' => 'PUB2', 'privkey' => 'PRIV2'],
['domain' => 'other.de', 'selector' => 'dkim', 'pubkey' => 'X', 'privkey' => 'X']],
'ratelimit' => '100 / 1h',
'filterconf' => [['prefid' => 1, 'object' => 'alt.de', 'option' => 'whitelist_from', 'value' => '*@partner.example'],
['prefid' => 2, 'object' => 'alt.de', 'option' => 'highspamlevel', 'value' => '12']],
'bcc_maps' => [['id' => 3, 'local_dest' => '@alt.de', 'bcc_dest' => 'archive@example.org', 'domain' => 'alt.de', 'type' => 'rcpt', 'active' => 1]],
'footer' => ['domain' => 'alt.de', 'html' => '<p>Alt</p>', 'plain' => 'Alt', 'mbox_exclude' => '[]', 'alias_domain_exclude' => '[]', 'skip_replies' => 0],
'mta_sts' => ['id' => 1700000000, 'domain' => 'alt.de', 'version' => 'STSv1', 'mode' => 'enforce', 'mx' => 'mail.alt.de', 'max_age' => '86400', 'active' => 1],
'domain_admins' => [['id' => 4, 'username' => 'da', 'domain' => 'alt.de', 'active' => 1], ['id' => 5, 'username' => 'gone', 'domain' => 'alt.de', 'active' => 1]],
'groups' => [['id' => 7, 'domain' => 'alt.de', 'name' => 'Team', 'description' => 'Alle', 'address' => 'team@alt.de', 'sender_allowed' => 1,
'sogo_visible' => 1, 'internal' => 0, 'active' => 1, 'priority' => 5, 'idp_ref' => null, 'template_id' => 3],
['id' => 8, 'domain' => 'alt.de', 'name' => 'Broken', 'description' => '', 'address' => null]],
'mail_policies' => [['id' => 3, 'domain' => 'alt.de', 'name' => 'Strict', 'send_mode' => 'list', 'send_allow' => '["group:7", "group:8", "x@alt.de"]',
'send_block' => '[]', 'receive_mode' => 'all', 'receive_allow' => '[]', 'receive_block' => '[]', 'group_members' => ['Team', 'Gone']]],
'retention_rules' => [['id' => 9, 'domain' => 'alt.de', 'name' => 'Papierkorb', 'folders' => '["trash"]', 'days' => 30, 'group_members' => []]],
];
}
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 testADeletedDomainIsCreatedWithItsSettings(): void
{
$result = $this->service()->restore(20, self::data())['domain'];
$domain = $this->repo->rows('domain', ['domain' => 'alt.de'])[0];
// the row of the backup with its creation time; a relayhost that is gone is not used
$this->assertSame(['Alt GmbH', 5, 4096, '0', '2020-01-01 00:00:00'], [$domain['description'], $domain['mailboxes'], $domain['quota'], $domain['relayhost'],
$domain['created']]);
$this->assertSame(['kunde'], array_column($this->repo->tables['tags_domain'], 'tag_name'));
// effects of the legacy domain add: domain map, SOGo restart, external send-as rights to the domain dropped
$this->assertSame(['alt.de', 'alt.com'], $this->redis['domain_map']);
$this->assertSame(1, $this->sogoRestarts);
$this->assertSame([], $this->repo->tables['sender_acl']);
// alias domains that are free; DKIM keys of the domain and its alias domains
$this->assertSame([['alt.com', 'alt.de']], array_map(fn(array $a): array => [$a['alias_domain'], $a['target_domain']], $this->repo->tables['alias_domain']));
$this->assertSame(['alt.de', 'alt.com'], array_keys($this->redis['dkim']));
$this->assertSame('PRIV2', $this->redis['dkim']['alt.com']['privkey']);
$this->assertSame(['alt.de' => '100 / 1h'], $this->redis['ratelimit']);
$this->assertSame([['whitelist_from', '*@partner.example'], ['highspamlevel', '12']],
array_map(fn(array $f): array => [$f['option'], $f['value']], $this->repo->tables['filterconf']));
$this->assertSame(['@alt.de', 'archive@example.org'], [$this->repo->tables['bcc_maps'][0]['local_dest'], $this->repo->tables['bcc_maps'][0]['bcc_dest']]);
$this->assertSame(['<p>Alt</p>', 'enforce'], [$this->repo->tables['domain_wide_footer'][0]['html'], $this->repo->tables['mta_sts'][0]['mode']]);
$this->assertSame([['da', 'alt.de']], array_map(fn(array $a): array => [$a['username'], $a['domain']], $this->repo->tables['domain_admins']));
// groups through the group module (no template), then policies and rules with the groups' new ids
$this->assertSame([['admin', ['domain' => 'alt.de', 'name' => 'Team', 'description' => 'Alle', 'sender_allowed' => 1, 'sogo_visible' => 1,
'internal' => 0, 'active' => 1, 'priority' => 5, 'address' => 'team@alt.de']]], $this->createdGroups);
$policy = $this->repo->rows('mail_policy', ['name' => 'Strict'])[0];
$this->assertSame(['["group:101","x@alt.de"]', 'list'], [$policy['send_allow'], $policy['send_mode']]);
$this->assertSame([[$policy['id'], 101]], array_map(fn(array $m): array => [$m['policy_id'], $m['group_id']], $this->repo->tables['mail_policy_member']));
$this->assertSame(['Papierkorb', '["trash"]'], [$this->repo->tables['retention_rule'][0]['name'], $this->repo->tables['retention_rule'][0]['folders']]);
$this->assertSame(1, $result['created']);
$this->assertSame([
'relayhost: does not exist',
'domain admin gone: does not exist',
'group Broken: group_address_invalid',
'Strict (send_allow): group:8 dropped (group does not exist)',
'Strict: group Gone does not exist',
], $result['errors']);
// domain, alias domain, 2 filterconf, BCC, footer, MTA-STS, domain admin, 2 DKIM keys, rate limit, group, policy, rule
$this->assertSame([14, 1], [$result['added'], $result['skipped']]);
}
public function testADomainCreatedMeanwhileGetsWhatIsMissing(): void
{
$this->repo->tables['domain'][] = ['domain' => 'alt.de', 'description' => 'Neu', 'aliases' => 10, 'mailboxes' => 1, 'quota' => 100, 'maxquota' => 100,
'relayhost' => '0', 'active' => 1];
$this->repo->tables['mailbox_group'][] = ['id' => 50, 'domain' => 'alt.de', 'name' => 'Team', 'address' => null];
$this->redis['dkim']['alt.de'] = ['selector' => 'new', 'pubkey' => 'NEW', 'privkey' => 'NEW'];
$result = $this->service()->restore(20, self::data())['domain'];
$this->assertSame([0, 'Neu'], [$result['created'], $this->repo->rows('domain', ['domain' => 'alt.de'])[0]['description']]);
$this->assertSame([0, 'NEW'], [$this->sogoRestarts, $this->redis['dkim']['alt.de']['pubkey']]);
// the existing group gets the policy's assignments
$this->assertSame([], $this->createdGroups);
$this->assertSame([50], array_column($this->repo->tables['mail_policy_member'], 'group_id'));
$again = $this->service()->restore(20, self::data())['domain'];
$this->assertSame(0, $again['added']);
}
public function testOnlyRunningDomainRestoresOfAdmins(): void
{
$this->repo->tables['alias_domain'][] = ['alias_domain' => 'alt.de', 'target_domain' => 'firma.de', 'active' => 1];
$this->assertSame(['alt.de is an alias domain now'], $this->service()->restore(20, self::data())['domain']['errors']);
$this->assertSame([], $this->repo->rows('domain', ['domain' => 'alt.de']));
$this->expectUserError('backup_export_invalid', fn() => $this->service()->restore(20, ['domain' => ['domain' => 'other.de']]));
$this->expectUserError('backup_job_not_running', fn() => $this->service()->restore(21, self::data()));
$this->job->params['create_domain'] = 0;
$this->expectUserError('access_denied', fn() => $this->service()->restore(20, self::data()));
$this->job->params['create_domain'] = 1;
$this->job->params['requested_role'] = 'domainadmin';
$this->expectUserError('access_denied', fn() => $this->service()->restore(20, self::data()));
$this->job->params['requested_role'] = 'admin';
$this->job->state = 'done';
$this->expectUserError('backup_job_not_running', fn() => $this->service()->restore(20, self::data()));
$this->job->state = 'running';
$this->repo->busyLocks = ['20:domain'];
$this->expectUserError('backup_job_busy', fn() => $this->service()->restore(20, self::data()));
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Repository\ExportRepository;
use Mailcow\Backup\Service\ExportService;
use Mailcow\Core\UserError;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ExportServiceTest extends TestCase
{
private ExportRepository&MockObject $repo;
private LegacyDomain&MockObject $legacy;
/** @var string[] usernames of the domain */
private array $usernames = ['anna@firma.de', 'bob@firma.de'];
protected function setUp(): void
{
$this->repo = $this->createMock(ExportRepository::class);
$this->repo->method('dbVersion')->willReturn('08112026_1700');
$this->repo->method('rows')->willReturnCallback(fn(string $table, string $column, string $value): array => match ($table) {
'domain' => ($value === 'firma.de') ? [['domain' => 'firma.de', 'description' => 'Firma', 'mailboxes' => 10, 'relayhost' => '0']] : [],
'alias_domain' => [['alias_domain' => 'firma.com', 'target_domain' => 'firma.de', 'active' => 1]],
'tags_domain' => [['tag_name' => 'kunde', 'domain' => 'firma.de']],
'filterconf' => [['prefid' => 1, 'object' => 'firma.de', 'option' => 'whitelist_from', 'value' => '*@partner.example']],
'domain_wide_footer' => [['domain' => 'firma.de', 'html' => '<p>Firma</p>', 'plain' => 'Firma']],
'domain_admins' => [['id' => 1, 'username' => 'da', 'domain' => 'firma.de', 'active' => 1]],
'mailbox_group' => [['id' => 7, 'domain' => 'firma.de', 'name' => 'Team', 'address' => 'team@firma.de']],
default => [],
});
$this->repo->method('domainRules')->willReturnCallback(fn(string $table): array => ($table === 'mail_policy')
? [['id' => 3, 'domain' => 'firma.de', 'name' => 'Strict', 'send_allow' => '["group:7"]', 'group_members' => ['Team']]] : []);
$this->repo->method('mailboxesAfter')->willReturnCallback(fn(string $domain, string $after, int $limit): array => array_slice(array_values(array_filter(
array_map(fn(string $username): array => ['username' => $username, 'password' => '{SSHA256}hash', 'attributes' => '{}', 'quota' => 0], $this->usernames),
fn(array $row): bool => $row['username'] > $after)), 0, $limit));
$this->repo->method('aliases')->willReturn([['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'group_id' => null]]);
$this->repo->method('aliasesPointingTo')->willReturn([
['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'group_id' => null],
['id' => 2, 'address' => 'info@other.de', 'goto' => 'Anna@firma.de, bob@firma.de,x@ext.example', 'group_id' => null],
['id' => 3, 'address' => 'team@firma.de', 'goto' => 'anna@firma.de', 'group_id' => 7],
['id' => 4, 'address' => 'list@firma.de', 'goto' => 'notamailbox@firma.de', 'group_id' => null],
]);
$this->repo->method('rowsOfMailboxes')->willReturnCallback(fn(string $table, string $column, array $usernames): array =>
array_values(array_filter(match ($table) {
'tags_mailbox' => [['tag_name' => 'vip', 'username' => 'anna@firma.de']],
'user_acl' => [['username' => 'anna@firma.de', 'spam_alias' => 1]],
'fido2' => [['username' => 'bob@firma.de', 'credentialId' => "\x00\xff\xfe", 'friendlyName' => 'Key']],
'sender_acl' => ($column === 'send_as') ? [['id' => 5, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'anna@firma.de']] : [],
// anna forwards with a copy
'alias' => [['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de,fwd@ext.example', 'group_id' => null]],
default => [],
}, fn(array $row): bool => in_array(strtolower($row[$column]), $usernames, true))));
$this->repo->method('usernames')->willReturnCallback(fn(): array => $this->usernames);
$this->repo->method('groupMemberships')->willReturn([['username' => 'anna@firma.de', 'id' => '7', 'name' => 'Team', 'domain' => 'firma.de', 'source' => 'manual']]);
$this->repo->method('assignments')->willReturnCallback(fn(string $table, string $member, string $key, array $usernames): array =>
($table === 'retention_rule' && in_array('bob@firma.de', $usernames, true)) ? [['username' => 'bob@firma.de', 'id' => 5, 'name' => 'Papierkorb', 'domain' => 'firma.de']] : []);
$this->repo->method('syncJobs')->willReturnCallback(fn(array $usernames): array => in_array('anna@firma.de', $usernames, true)
? [['id' => 9, 'user2' => 'anna@firma.de', 'user1' => 'a@old.de', 'password1' => 'pw', 'source_host1' => 'imap.old.de', 'source_port1' => 993]] : []);
$this->legacy = $this->createMock(LegacyDomain::class);
$this->legacy->method('dkim')->willReturnCallback(fn(string $domain): ?array => ($domain === 'firma.de')
? ['selector' => 'dkim', 'pubkey' => 'PUB', 'privkey' => 'PRIV'] : null);
$this->legacy->method('rateLimits')->willReturnCallback(fn(array $objects): array =>
array_intersect_key(['firma.de' => '100 / 1h', 'bob@firma.de' => '10 / 1m'], array_flip($objects)));
}
private function export(?string $after = null, int $pageSize = ExportService::PAGE_SIZE): array
{
return (new ExportService($this->repo, $this->legacy))->export(' Firma.de ', $after, $pageSize);
}
public function testTheExportHoldsEverythingOfTheMailboxes(): void
{
$export = $this->export();
$this->assertSame([1, '08112026_1700', 'firma.de', null, 1], [$export['version'], $export['db_version'], $export['domain'],
$export['next_after'], count($export['aliases'])]);
$anna = $export['mailboxes']['anna@firma.de'];
$bob = $export['mailboxes']['bob@firma.de'];
// password hashes and secrets are part of the export on purpose
$this->assertSame(['{SSHA256}hash', ['vip']], [$anna['mailbox']['password'], $anna['mailbox']['tags']]);
$this->assertSame(['username' => 'anna@firma.de', 'spam_alias' => 1], $anna['user_acl']);
$this->assertSame([null, null, '10 / 1m'], [$bob['user_acl'], $bob['pushover'], $bob['ratelimit']]);
$this->assertSame([[7, 'Team', 'firma.de', 'manual']], array_map('array_values', $anna['groups']));
$this->assertSame('Papierkorb', $bob['retention_rules'][0]['name']);
$this->assertSame([['id' => 5, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'anna@firma.de']], $anna['sender_acl_granted']);
$this->assertSame('pw', $anna['imapsync'][0]['password1']);
// binary values survive JSON
$this->assertSame(['base64' => base64_encode("\x00\xff\xfe")], $bob['fido2'][0]['credentialId']);
$this->assertNotFalse(json_encode($export));
// aliases of any domain naming the mailbox, without its own alias and group addresses: once, with the first page
$this->assertSame([[2], [2], [], []], [array_column($export['mailbox_aliases']['anna@firma.de'], 'id'),
array_column($export['mailbox_aliases']['bob@firma.de'], 'id'), $anna['aliases'], $bob['aliases']]);
// the own alias with its forwarding comes with the mailbox
$this->assertSame(['anna@firma.de,fwd@ext.example', null], [$anna['own_alias']['goto'], $bob['own_alias']]);
$this->assertSame(['mailbox', 'user_acl', 'pushover', 'ratelimit', 'own_alias', 'aliases', 'groups', 'mail_policies', 'retention_rules', 'imapsync', 'sender_acl',
'sender_acl_granted', 'sieve_filters', 'filterconf', 'spamalias', 'bcc_maps', 'app_passwd', 'tfa', 'fido2', 'identity_link'], array_keys($anna));
}
public function testTheDomainDataRecreatesTheDomain(): void
{
$data = $this->export()['domain_data'];
$this->assertSame(['domain', 'tags', 'alias_domains', 'dkim', 'ratelimit', 'filterconf', 'bcc_maps', 'footer', 'mta_sts', 'domain_admins', 'groups',
'mail_policies', 'retention_rules'], array_keys($data));
$this->assertSame(['Firma', ['kunde'], 'firma.com', '100 / 1h', null], [$data['domain']['description'], $data['tags'],
$data['alias_domains'][0]['alias_domain'], $data['ratelimit'], $data['mta_sts']]);
// DKIM keys of the domain and its alias domains, with the private key
$this->assertSame([['domain' => 'firma.de', 'selector' => 'dkim', 'pubkey' => 'PUB', 'privkey' => 'PRIV']], $data['dkim']);
$this->assertSame(['Team'], $data['mail_policies'][0]['group_members']);
$this->assertSame('<p>Firma</p>', $data['footer']['html']);
}
public function testBigDomainsComeInPages(): void
{
$this->usernames = ['a@firma.de', 'anna@firma.de', 'bob@firma.de'];
$first = $this->export(null, 2);
$last = $this->export($first['next_after'], 2);
// pages by username: the next one starts after the last username, whatever was added or deleted meanwhile
$this->assertSame([['a@firma.de', 'anna@firma.de'], 'anna@firma.de'], [array_keys($first['mailboxes']), $first['next_after']]);
$this->assertSame([['bob@firma.de'], null], [array_keys($last['mailboxes']), $last['next_after']]);
// the domain's data and aliases come with the first page only
$this->assertArrayHasKey('domain_data', $first);
$this->assertSame(['version', 'domain', 'mailboxes', 'next_after'], array_keys($last));
// the aliases naming mailboxes of later pages come with the first page as well
$this->assertSame([[2], []], [array_column($first['mailbox_aliases']['bob@firma.de'], 'id'), $last['mailboxes']['bob@firma.de']['aliases']]);
$this->assertSame('{}', json_encode($this->export('zzz@firma.de', 2)['mailboxes']));
}
public function testUnknownDomain(): void
{
$this->expectException(UserError::class);
(new ExportService($this->repo, $this->legacy))->export('nix.de');
}
}
+604
View File
@@ -0,0 +1,604 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Model\Snapshot;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Backup\Repository\SnapshotRepository;
use Mailcow\Backup\Service\JobService;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class JobServiceTest extends TestCase
{
private JobRepository&MockObject $jobs;
private SnapshotRepository&MockObject $snapshots;
private SettingsRepository&MockObject $settingsRepo;
private array $settings = ['target' => 'local', 'password' => 'repo-password', 'password_confirmed' => '1'];
/** @var Job[] queued or running jobs */
private array $active = [];
/** @var Job[] inserted jobs */
private array $inserted = [];
/** @var Job[] jobs by id */
private array $stored = [];
protected function setUp(): void
{
$this->settingsRepo = $this->createMock(SettingsRepository::class);
$this->settingsRepo->method('load')->willReturnCallback(fn(): array => $this->settings);
$this->jobs = $this->createMock(JobRepository::class);
$this->jobs->method('insertExclusive')->willReturnCallback(function (Job $job, callable $check): int {
$check($this->active);
$job->id = 100 + count($this->inserted);
$this->inserted[] = $job;
return $job->id;
});
$this->jobs->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true));
$this->jobs->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string =>
['anna@firma.de' => 'firma.de', 'bob@firma.de' => 'firma.de', 'carl@tochter.de' => 'tochter.de'][$username] ?? null);
$this->jobs->method('mailboxesOf')->willReturnCallback(fn(string $domain): array =>
['firma.de' => ['anna@firma.de', 'bob@firma.de'], 'tochter.de' => []][$domain] ?? []);
$this->jobs->method('mailboxNames')->willReturnCallback(fn(string $domain): array =>
['firma.de' => ['anna@firma.de' => 'Anna', 'bob@firma.de' => 'Bob'], 'tochter.de' => ['carl@tochter.de' => 'Carl']][$domain] ?? []);
$this->jobs->method('domainCreated')->willReturnCallback(fn(string $domain): ?int => ['firma.de' => 1500, 'tochter.de' => 100][$domain] ?? null);
$this->jobs->method('domainsAndUsernames')->willReturnCallback(function (array $domains): array {
$names = ['firma.de' => ['anna@firma.de', 'bob@firma.de'], 'tochter.de' => ['carl@tochter.de']];
$existing = array_fill_keys(array_values(array_intersect($domains, array_keys($names))), true);
$usernames = array_map(fn(array $list): array => array_fill_keys($list, true), array_intersect_key($names, array_flip($domains)));
return [$existing, $usernames];
});
$this->jobs->method('find')->willReturnCallback(fn(int $id): ?Job => $this->stored[$id] ?? null);
$this->jobs->method('active')->willReturnCallback(fn(): array => $this->active);
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturnCallback(fn(string $id): ?Snapshot => array_values(array_filter($this->snapshotList(),
fn(Snapshot $s): bool => $s->id === $id || $s->shortId === $id))[0] ?? null);
$this->snapshots->method('all')->willReturnCallback(fn(): array => $this->snapshotList());
}
/** @return Snapshot[] newest first */
private function snapshotList(): array
{
// alt.de was deleted since, so were dora@firma.de and the former firma.de of snapshot d (firma.de is newer)
$content = gzencode(json_encode(['domains' => ['firma.de' => ['created' => null, 'mailboxes' => ['anna@firma.de' => 'Anna', 'bob@firma.de' => 'Bob',
'dora@firma.de' => 'Dora']], 'tochter.de' => ['created' => null, 'mailboxes' => ['carl@tochter.de' => 'Carl']],
'alt.de' => ['created' => null, 'mailboxes' => ['x@alt.de' => 'X']]]]));
return [
Snapshot::fromRow(['id' => str_repeat('b', 64), 'short_id' => 'bbbbbbbb', 'time' => 2000, 'hostname' => 'mail', 'tags' => 'mailcow',
'summary' => '{"data_added": 5}', 'domains' => '{"firma.de": null, "tochter.de": null, "alt.de": null}', 'content' => $content]),
Snapshot::fromRow(['id' => str_repeat('d', 64), 'short_id' => 'dddddddd', 'time' => 1200, 'hostname' => 'mail', 'tags' => '',
'summary' => null, 'domains' => '{"firma.de": null}']),
Snapshot::fromRow(['id' => str_repeat('a', 64), 'short_id' => 'aaaaaaaa', 'time' => 1000, 'hostname' => 'mail', 'tags' => '',
'summary' => null, 'domains' => '{"tochter.de": null}']),
Snapshot::fromRow(['id' => str_repeat('c', 64), 'short_id' => 'cccccccc', 'time' => 500, 'hostname' => 'other', 'tags' => '',
'summary' => null, 'domains' => null]),
];
}
private function service(?Context $context = null, string $timezone = 'Europe/Berlin'): JobService
{
$context ??= FakeContext::admin();
return new JobService($this->jobs, $this->snapshots, new SettingsService($this->settingsRepo, $context), $context, $timezone);
}
private static function domainAdmin(array $acl = []): FakeContext
{
return FakeContext::domainAdmin(['anna@firma.de', 'bob@firma.de'], ['firma.de'], $acl);
}
/** A job requested at unix time 2000, restores by a domain admin unless $params say otherwise */
private static function job(int $id, string $type, string $state = 'queued', string $domain = '', array $params = [], int $created = 2000): Job
{
$job = new Job($type);
$job->id = $id;
$job->state = $state;
$job->domain = $domain;
$job->params = $params + (($type === 'restore') ? ['requested_role' => 'domainadmin'] : []);
$job->created = $created;
return $job;
}
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());
}
}
private static function restore(array $input = []): array
{
return $input + ['snapshot' => 'bbbbbbbb', 'scope' => 'mailbox', 'mailboxes' => ['anna@firma.de'], 'components' => ['mails', 'aliases']];
}
public function testAdminsQueueOneJobOfEachType(): void
{
$job = $this->service()->create(['type' => 'check']);
$this->assertSame(['check', 'queued', 'admin', 100], [$job->type, $job->state, $job->requestedBy, $job->id]);
$this->active = [self::job(1, 'check', 'running'), self::job(2, 'restore')];
$this->expectUserError(['backup_job_active', 'check'], fn() => $this->service()->create(['type' => 'check']));
$this->assertSame('prune', $this->service()->create(['type' => 'prune'])->type);
$this->expectUserError(['backup_job_type_invalid', 'restore'], fn() => $this->service()->create(['type' => 'restore']));
$this->expectUserError(['backup_job_type_invalid', '&lt;x&gt;'], fn() => $this->service()->create(['type' => '<x>']));
}
public function testJobsNeedAConfiguredRepository(): void
{
$this->settings['password_confirmed'] = '0';
$this->expectUserError('backup_password_not_confirmed', fn() => $this->service()->create(['type' => 'backup']));
// testing the connection needs no confirmation
$this->assertSame('init', $this->service()->create(['type' => 'init'])->type);
$this->settings['password'] = '';
$this->expectUserError('backup_not_configured', fn() => $this->service()->create(['type' => 'init']));
$this->expectUserError('backup_not_configured', fn() => $this->service()->restore(self::restore()));
}
public function testOnlyWritingAdminsQueueJobs(): void
{
foreach ([new Context('admin', 'API', [], false, 'ro'), self::domainAdmin(['syncjobs' => '1']), FakeContext::user('anna@firma.de'), Context::system()] as $context) {
$this->expectUserError('access_denied', fn() => $this->service($context)->create(['type' => 'backup']));
}
$this->assertSame([], $this->inserted);
}
public function testRestoreOfMailboxes(): void
{
$job = $this->service()->restore(self::restore(['mailboxes' => "Anna@firma.de\nbob@firma.de", 'components' => 'password, mails,aliases,mails',
'mail_target' => 'original']));
$this->assertSame(['restore', 'files', 'firma.de', 'admin'], [$job->type, $job->step, $job->domain, $job->requestedBy]);
$this->assertSame(['snapshot' => str_repeat('b', 64), 'snapshot_time' => 2000, 'scope' => 'mailbox', 'domain' => 'firma.de',
'mailboxes' => ['anna@firma.de', 'bob@firma.de'], 'components' => ['mails', 'aliases', 'password'], 'mail_target' => 'original',
'requested_role' => 'admin', 'create_domain' => 0, 'create_mailboxes' => []], $job->params);
}
public function testRestoreOfADomain(): void
{
// the snapshot's mailboxes of the domain; deleted ones are created
$job = $this->service()->restore(self::restore(['scope' => 'domain', 'domain' => 'Firma.de', 'mailboxes' => 'ignored@firma.de']));
$this->assertSame([['anna@firma.de', 'bob@firma.de', 'dora@firma.de'], ['dora@firma.de'], 0, 'folder'],
[$job->params['mailboxes'], $job->params['create_mailboxes'], $job->params['create_domain'], $job->params['mail_target']]);
// snapshots without content: the current mailboxes
$this->expectUserError('backup_mailboxes_empty', fn() => $this->service()->restore(self::restore(['snapshot' => 'aaaaaaaa', 'scope' => 'domain',
'domain' => 'tochter.de'])));
$this->expectUserError(['domain_not_found', 'nix.de'], fn() => $this->service()->restore(self::restore(['scope' => 'domain', 'domain' => 'nix.de'])));
}
public function testDeletedMailboxesAndDomainsAreCreated(): void
{
$job = $this->service()->restore(self::restore(['mailboxes' => ['anna@firma.de', 'dora@firma.de']]));
$this->assertSame([['dora@firma.de'], 0], [$job->params['create_mailboxes'], $job->params['create_domain']]);
$job = $this->service()->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de']));
$this->assertSame(['alt.de', ['x@alt.de'], ['x@alt.de'], 1], [$job->domain, $job->params['mailboxes'], $job->params['create_mailboxes'],
$job->params['create_domain']]);
$this->assertSame(1, $this->service()->restore(self::restore(['mailboxes' => ['x@alt.de']]))->params['create_domain']);
// only mailboxes the snapshot knows, and only from snapshots with content
$this->expectUserError(['backup_mailbox_not_found', 'dora@firma.de'], fn() => $this->service()->restore(self::restore(['snapshot' => 'dddddddd',
'mailboxes' => ['dora@firma.de']])));
$this->expectUserError(['domain_not_found', 'alt.de'], fn() => $this->service()->restore(self::restore(['snapshot' => 'aaaaaaaa', 'scope' => 'domain',
'domain' => 'alt.de'])));
// domain admins create mailboxes in their domains, never domains
$da = self::domainAdmin();
$this->assertSame(['dora@firma.de'], $this->service($da)->restore(self::restore(['mailboxes' => ['dora@firma.de']]))->params['create_mailboxes']);
$this->expectUserError(['domain_not_found', 'alt.de'], fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'alt.de'])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['x@alt.de']])));
}
public function testInvalidRestores(): void
{
$cases = [
[['snapshot' => 'ffff'], ['backup_snapshot_not_found', 'ffff']],
[['snapshot' => ''], ['backup_snapshot_not_found', '']],
[['scope' => 'server'], 'backup_scope_invalid'],
[['mailboxes' => []], 'backup_mailboxes_empty'],
[['mailboxes' => ['anna@firma.de', 'gone@firma.de']], ['backup_mailbox_not_found', 'gone@firma.de']],
[['mailboxes' => ['anna@firma.de', 'carl@tochter.de']], 'backup_mailboxes_one_domain'],
[['domain' => 'tochter.de'], 'backup_mailboxes_one_domain'],
[['mailboxes' => array_map(fn(int $i): string => "u$i@firma.de", range(1, 1001))], ['backup_entries_exceeded', 1000]],
[['components' => []], ['backup_components_invalid', '']],
[['components' => ['mails', 'everything']], ['backup_components_invalid', 'everything']],
[['components' => ['policies']], ['backup_components_invalid', 'policies']],
[['components' => ['sogo_public']], ['backup_components_invalid', 'sogo_public']],
[['components' => ['mails', 'sieve_activate']], ['backup_component_requires', 'sieve_activate', 'filters']],
[['mail_target' => 'elsewhere'], 'backup_mail_target_invalid'],
// the snapshot holds tochter.de only
[['snapshot' => 'aaaaaaaa'], ['backup_domain_not_in_snapshot', 'firma.de']],
];
foreach ($cases as [$input, $error]) {
$this->expectUserError($error, fn() => $this->service()->restore(self::restore($input)));
}
// snapshots not made by mailcow have no domain list: the runner reports what is missing
$this->assertNotNull($this->service()->restore(self::restore(['snapshot' => 'cccccccc'])));
}
public function testAMailboxIsRestoredOnceAtATime(): void
{
$this->active = [self::job(1, 'restore', 'running', 'firma.de', ['mailboxes' => ['bob@firma.de']]), self::job(2, 'backup', 'running')];
$this->expectUserError(['backup_restore_active', 'bob@firma.de'], fn() => $this->service()->restore(self::restore(['mailboxes' => ['anna@firma.de', 'bob@firma.de']])));
$this->assertCount(1, [$this->service()->restore(self::restore())]);
}
public function testDomainAdminsRestoreTheirMailboxesWithinTheirAcl(): void
{
$da = self::domainAdmin(['syncjobs' => '1', 'protocol_access' => '1']);
$job = $this->service($da)->restore(self::restore(['components' => ['mails', 'sogo', 'syncjobs', 'settings']]));
$this->assertSame(['domainadmin', 'da', 1, 0, 0], [$job->params['requested_role'], $job->requestedBy, $job->params['acl']['syncjobs'],
$job->params['acl']['unlimited_quota'], $job->params['acl']['app_passwds']]);
$this->assertSame([1, ['firma.de']], [$job->params['acl']['protocol_access'], $job->params['allowed_domains']]);
$this->assertSame('firma.de', $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'firma.de']))->domain);
// components he could not manage himself
$this->expectUserError(['backup_component_denied', 'app_passwords'], fn() => $this->service($da)->restore(self::restore(['components' => ['app_passwords']])));
$this->expectUserError(['backup_component_denied', 'groups'], fn() => $this->service($da)->restore(self::restore(['components' => ['groups']])));
$this->expectUserError(['backup_component_denied', 'sieve_activate'], fn() => $this->service($da)->restore(self::restore(['components' => ['sieve_activate']])));
$retention = self::domainAdmin(['retention' => '1']);
$this->assertNotNull($this->service($retention)->restore(self::restore(['components' => ['retention_rules']])));
$this->expectUserError(['backup_component_denied', 'mail_policies'], fn() => $this->service($retention)->restore(self::restore(['components' => ['mail_policies']])));
// snapshots made before his domain was created hold a former domain of that name
$this->expectUserError(['backup_snapshot_not_found', 'dddddddd'], fn() => $this->service($da)->restore(self::restore(['snapshot' => 'dddddddd'])));
// mailboxes and domains of others
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['mailboxes' => ['carl@tochter.de']])));
$this->expectUserError('access_denied', fn() => $this->service($da)->restore(self::restore(['scope' => 'domain', 'domain' => 'tochter.de'])));
}
public function testUsersAndReadOnlyKeysCannotRestore(): void
{
foreach ([FakeContext::user('anna@firma.de'), new Context('admin', 'API', [], false, 'ro'), Context::system()] as $context) {
$this->expectUserError('access_denied', fn() => $this->service($context)->restore(self::restore()));
}
$this->assertSame([], $this->inserted);
}
public function testVisibilityOfJobs(): void
{
$this->stored = [1 => self::job(1, 'backup', 'done'), 2 => self::job(2, 'restore', 'done', 'firma.de', ['mailboxes' => ['anna@firma.de']]),
3 => self::job(3, 'restore', 'done', 'tochter.de'), 4 => self::job(4, 'restore', 'done', 'firma.de', [], 1400)];
$da = self::domainAdmin();
$this->assertNotNull($this->service()->get(1));
$this->assertNotNull($this->service(new Context('admin', 'API', [], false, 'ro'))->get(3));
$this->assertSame([null, 2, null], [$this->service($da)->get(1), $this->service($da)->get(2)?->id, $this->service($da)->get(3)]);
// a restore of a former domain of the name (before firma.de was created at 1500)
$this->assertNull($this->service($da)->get(4));
$this->assertSame(4, $this->service()->get(4)?->id);
$this->assertNull($this->service(FakeContext::user('anna@firma.de'))->get(2));
// the list is filtered in SQL: the repository gets the caller and the limit
$this->jobs->expects($this->exactly(2))->method('visible')->willReturnCallback(function (Context $context, int $limit): array {
$this->assertContains($limit, [100, 1000]);
return [];
});
$this->service($da)->list();
$this->service()->list(5000);
}
public function testQueuedAndRunningJobsCanBeCancelled(): void
{
$mails = self::job(5, 'restore', 'running', 'firma.de');
$mails->step = 'mails_running';
$files = self::job(6, 'restore', 'running', 'firma.de');
$files->step = 'files';
$this->stored = [1 => self::job(1, 'backup'), 2 => self::job(2, 'backup', 'running'), 3 => self::job(3, 'restore', 'queued', 'firma.de'),
4 => self::job(4, 'restore', 'queued', 'tochter.de'), 5 => $mails, 6 => $files, 7 => self::job(7, 'check', 'done'),
8 => self::job(8, 'restore', 'queued', 'firma.de', ['requested_role' => 'admin'])];
$this->jobs->method('cancel')->willReturn(true);
$this->assertSame('cancelled', $this->service()->cancel(1)->state);
$this->assertSame('cancelled', $this->service()->cancel(2)->state);
$this->assertSame('cancelled', $this->service()->cancel(6)->state);
// the dovecot step runs to its end
$this->expectUserError(['backup_job_not_cancellable', 5], fn() => $this->service()->cancel(5));
$this->expectUserError(['backup_job_not_cancellable', 7], fn() => $this->service()->cancel(7));
$this->assertSame('cancelled', $this->service(self::domainAdmin())->cancel(3)->state);
$this->expectUserError('access_denied', fn() => $this->service(self::domainAdmin())->cancel(4));
// a restore an admin requested is not the domain admin's to cancel
$this->expectUserError('access_denied', fn() => $this->service(self::domainAdmin())->cancel(8));
$this->assertSame('cancelled', $this->service()->cancel(8)->state);
$this->expectUserError('access_denied', fn() => $this->service(self::domainAdmin())->cancel(1));
$this->expectUserError('access_denied', fn() => $this->service(new Context('admin', 'API', [], false, 'ro'))->cancel(1));
$this->expectUserError('access_denied', fn() => $this->service()->cancel(99));
}
public function testTheRunnerMayHaveFinishedMeanwhile(): void
{
$this->stored = [1 => self::job(1, 'backup', 'running')];
$this->jobs->method('cancel')->willReturn(false);
$this->expectUserError(['backup_job_not_cancellable', 1], fn() => $this->service()->cancel(1));
}
public function testContentOfASnapshot(): void
{
$this->assertSame(['snapshot' => str_repeat('b', 64), 'time' => 2000, 'available' => 1, 'domains' => [
['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => 3, 'missing' => 1],
['domain' => 'tochter.de', 'exists' => 1, 'mailboxes' => 1, 'missing' => 0],
['domain' => 'alt.de', 'exists' => 0, 'mailboxes' => 1, 'missing' => 1],
]], $this->service()->content('bbbbbbbb'));
$this->assertSame(['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => [['username' => 'anna@firma.de', 'name' => 'Anna', 'exists' => 1],
['username' => 'bob@firma.de', 'name' => 'Bob', 'exists' => 1], ['username' => 'dora@firma.de', 'name' => 'Dora', 'exists' => 0]]],
$this->service()->domainContent(str_repeat('b', 64), 'Firma.de'));
$this->assertSame([['username' => 'x@alt.de', 'name' => 'X', 'exists' => 0]], $this->service()->domainContent('bbbbbbbb', 'alt.de')['mailboxes']);
// without content: the manifest's domains with their current mailboxes
$this->assertSame(['snapshot' => str_repeat('a', 64), 'time' => 1000, 'available' => 0, 'domains' => [
['domain' => 'tochter.de', 'exists' => 1, 'mailboxes' => 1, 'missing' => 0]]], $this->service()->content('aaaaaaaa'));
$this->assertSame([['username' => 'carl@tochter.de', 'name' => 'Carl', 'exists' => 1]], $this->service()->domainContent('aaaaaaaa', 'tochter.de')['mailboxes']);
$this->assertSame([], $this->service()->content('nope'));
$this->assertSame([], $this->service()->domainContent('bbbbbbbb', 'nix.de'));
}
public function testSearchInASnapshot(): void
{
$names = fn(array $result): array => array_map(fn(array $domain): array => [$domain['domain'], $domain['exists'],
array_map(fn(array $m): string => $m['username'] . ($m['exists'] ? '' : ' (deleted)'), $domain['mailboxes'])], $result['domains']);
// local part, address and name; a matching domain comes with all its mailboxes
$this->assertSame([['firma.de', 1, ['dora@firma.de (deleted)']]], $names($this->service()->search('bbbbbbbb', 'DOR')));
$this->assertSame([['firma.de', 1, ['bob@firma.de']]], $names($this->service()->search('bbbbbbbb', 'Bob')));
$this->assertSame([['alt.de', 0, ['x@alt.de (deleted)']]], $names($this->service()->search('bbbbbbbb', 'alt')));
// "x@": local part only, "@d": the mailboxes of matching domains, "x@d": the address
$this->assertSame([['firma.de', 1, ['anna@firma.de']]], $names($this->service()->search('bbbbbbbb', 'ann@')));
$this->assertSame([['tochter.de', 1, ['carl@tochter.de']]], $names($this->service()->search('bbbbbbbb', '@tochter')));
$this->assertSame([['firma.de', 1, ['anna@firma.de']]], $names($this->service()->search('bbbbbbbb', 'na@firma.de')));
$this->assertSame([], $names($this->service()->search('bbbbbbbb', 'x@firma')));
$this->assertSame(['domains' => [], 'truncated' => 0], $this->service()->search('bbbbbbbb', '@'));
$this->assertSame(['domains' => [], 'truncated' => 0], $this->service()->search('bbbbbbbb', ''));
$this->assertSame(0, $this->service()->search('bbbbbbbb', 'a')['truncated']);
// snapshots without content: the current mailboxes
$this->assertSame([['tochter.de', 1, ['carl@tochter.de']]], $names($this->service()->search('aaaaaaaa', 'carl')));
// domain admins: their domains of newer snapshots only
$da = self::domainAdmin();
$this->assertSame([['firma.de', 1, ['anna@firma.de', 'bob@firma.de', 'dora@firma.de (deleted)']]], $names($this->service($da)->search('bbbbbbbb', '.de')));
$this->assertSame([], $this->service($da)->search('dddddddd', 'anna'));
$this->assertSame([], $this->service(FakeContext::user('anna@firma.de'))->search('bbbbbbbb', 'anna'));
$this->assertSame([], $this->service()->search('nope', 'anna'));
}
public function testSearchStopsAt500Mailboxes(): void
{
$mailboxes = [];
foreach (range(1, 600) as $i) {
$mailboxes["user$i@gross.de"] = "User $i";
}
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturn(Snapshot::fromRow(['id' => str_repeat('e', 64), 'short_id' => 'eeeeeeee', 'time' => 3000,
'domains' => '{"gross.de": null, "z.de": null}', 'content' => gzencode(json_encode(['domains' => ['gross.de' => ['created' => null, 'mailboxes' => $mailboxes],
'z.de' => ['created' => null, 'mailboxes' => ['user@z.de' => 'Z']]]]))]));
$result = $this->service()->search('eeeeeeee', 'user');
$this->assertSame([1, 1, 500], [$result['truncated'], count($result['domains']), count($result['domains'][0]['mailboxes'])]);
}
public function testJobsOfOneType(): void
{
$types = [];
$this->jobs->method('visible')->willReturnCallback(function (Context $context, int $limit, ?string $type) use (&$types): array {
$types[] = [$limit, $type];
return [];
});
$this->service()->list(0, 'restore');
$this->service()->list(20, '');
$this->service()->list(20, null);
$this->assertSame([], $this->service()->list(20, 'everything'));
$this->assertSame([[100, 'restore'], [20, null], [20, null]], $types);
}
public function testDomainAdminsSeeTheirDomainsOfNewerSnapshotsOnly(): void
{
$da = self::domainAdmin();
$this->assertSame([['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => 3, 'missing' => 1]], $this->service($da)->content('bbbbbbbb')['domains']);
$this->assertSame([], $this->service($da)->domainContent('bbbbbbbb', 'alt.de'));
$this->assertSame([], $this->service($da)->domainContent('bbbbbbbb', 'tochter.de'));
$this->assertSame([], $this->service($da)->content('dddddddd'));
$this->assertSame([], $this->service(FakeContext::user('anna@firma.de'))->content('bbbbbbbb'));
}
public function testSnapshotsForDomainAdminsShowOnlyTheirDomains(): void
{
$this->assertCount(4, $this->service()->snapshots());
$this->assertSame(['data_added' => 5], $this->service()->snapshots()[0]['summary']);
// snapshot d holds a former firma.de (older than the domain)
$this->assertSame([['id' => str_repeat('b', 64), 'short_id' => 'bbbbbbbb', 'time' => 2000, 'domains' => ['firma.de'], 'mode' => 'all']],
$this->service(self::domainAdmin())->snapshots());
$this->assertSame([], $this->service(FakeContext::user('anna@firma.de'))->snapshots());
}
public function testStatusAndNextRun(): void
{
$daily = '[{"days":[0,1,2,3,4,5,6],"time":"02:30"}]';
$this->settings += ['enabled' => '1', 'schedule' => $daily, 'state_runner_seen' => '1000', 'state_repo_size' => '4096',
'state_schedule_since' => '1794000000', 'state_schedule' => $daily];
$this->jobs->method('latestOfEachType')->willReturn(['backup' => self::job(7, 'backup', 'done')]);
$scheduled = [];
$this->jobs->method('scheduledSince')->willReturnCallback(function (int $since) use (&$scheduled): bool {
return in_array($since, $scheduled, true);
});
// 2026-11-08 01:00 Berlin, armed since 2026-11-06: yesterday's run (2026-11-07 02:30) is due until it was made
$status = $this->service()->status(1794096000);
$this->assertSame(1794015000, $status['next_run']);
$this->assertSame([1, 1, 1794095000, 0, 4096, 4, 2000, 7, null, null], [$status['enabled'], $status['configured'], $status['runner_age'],
$status['runner_ok'], $status['repo_size'], $status['snapshots'], $status['latest_snapshot'], $status['jobs']['backup']['id'],
$status['jobs']['check'], $status['secrets_error']]);
$scheduled[] = 1794015000;
$this->assertSame(1794101400, $this->service()->status(1794096000)['next_run']);
// 03:00: today's run is due until the schedule made it, then tomorrow
$this->assertSame(1794101400, $this->service()->status(1794103200)['next_run']);
$scheduled[] = 1794101400;
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
// switched on (armed) after today's time: no catch-up, tomorrow
$this->settings['state_schedule_since'] = '1794102000';
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
// not armed yet, or armed for another time: the next schedule time
$this->settings['state_schedule_since'] = '';
$this->assertSame(1794101400, $this->service()->status(1794096000)['next_run']);
$this->settings['state_schedule_since'] = '1794000000';
$this->settings['state_schedule'] = '[{"days":[0,1,2,3,4,5,6],"time":"01:00"}]';
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
$this->settings['state_schedule'] = $daily;
$this->assertSame(86400, $this->service()->status(1794103200)['schedule_gap']);
// Monday, Wednesday and Friday at 02:30 and 14:00: from Sunday 2026-11-08 03:00 Monday 02:30 (due until made), then
// Monday 14:00, from Monday 15:00 Wednesday 02:30; the gap from Friday 14:00 to Monday 02:30 (60.5 hours)
$this->settings['schedule'] = '[{"days":[1,3,5],"time":"02:30"},{"days":[1,3,5],"time":"14:00"}]';
$this->settings['state_schedule'] = $this->settings['schedule'];
$this->assertSame(1794187800, $this->service()->status(1794103200)['next_run']);
$this->assertSame(1794187800, $this->service()->status(1794189600)['next_run']);
$scheduled[] = 1794187800;
$this->assertSame(1794229200, $this->service()->status(1794189600)['next_run']);
$scheduled[] = 1794229200;
$this->assertSame(1794360600, $this->service()->status(1794232800)['next_run']);
$this->assertSame(217800, $this->service()->status(1794232800)['schedule_gap']);
// no entry: no run
$this->settings['schedule'] = '[]';
$this->assertNull($this->service()->status(1794232800)['next_run']);
$this->assertNull($this->service()->status(1794232800)['schedule_gap']);
// the schedule is off
$this->settings['enabled'] = '0';
$this->assertNull($this->service()->status(1794103200)['next_run']);
$this->assertSame([], $this->service(self::domainAdmin())->status());
// the runner cannot decrypt the credentials
$this->settings['state_secrets_error'] = 'credentials must be entered again';
$this->assertSame('credentials must be entered again', $this->service()->status(1794103200)['secrets_error']);
// RESTIC_BACKUP_MODE once the runner reported it
$this->assertNull($this->service()->status(1794103200)['mode']);
$this->settings['state_backup_mode'] = 'data';
$this->assertSame('data', $this->service()->status(1794103200)['mode']);
}
public function testDomainAdminsSeeJobsRestricted(): void
{
$job = self::job(9, 'restore', 'done', 'firma.de', ['requested_role' => 'admin']);
$job->result = ['mailboxes' => ['anna@firma.de' => ['aliases' => ['added' => 0, 'skipped' => 0,
'errors' => ['info@firma.de: goto bob@tochter.de dropped (no mailbox or alias)', 'x@firma.com: in use']]]]];
$job->error = 'restic: Fatal: unable to open repository at sftp:u1@backup.example.org:/srv/repo';
$job->log = "09:00 restore from s3:https://s3.example.org/bucket by admin@tochter.de\n09:01 anna@firma.de done";
$this->jobs->method('aliasDomainsOf')->willReturn(['firma.com']);
$view = $this->service(self::domainAdmin())->view($job, true);
$this->assertSame(['info@firma.de: goto … dropped (no mailbox or alias)', 'x@firma.com: in use'],
$view['result']['mailboxes']['anna@firma.de']['aliases']['errors']);
$this->assertSame('restic: Fatal: unable to open repository at …', $view['error']);
$this->assertSame("09:00 restore from … by …\n09:01 anna@firma.de done", $view['log']);
// admins see everything
$this->assertSame($job->error, $this->service()->view($job, true)['error']);
}
public function testADomainAdminRestoresADomainOnceAtATime(): void
{
$this->active = [self::job(1, 'restore', 'running', 'firma.de', ['mailboxes' => ['bob@firma.de']])];
$da = self::domainAdmin();
$this->expectUserError(['backup_restore_domain_active', 'firma.de'], fn() => $this->service($da)->restore(self::restore()));
// admins may queue restores of other mailboxes of the domain
$this->assertSame('firma.de', $this->service()->restore(self::restore())->domain);
}
public function testSnapshotsRecordTheDomainsCreationTime(): void
{
// snapshot f holds the current firma.de (created 1500), snapshot g a former one; content without manifest entry
// and manifest without content (its export failed) both count
$content = fn(int $created): string => gzencode(json_encode(['domains' => ['firma.de' => ['created' => $created,
'mailboxes' => ['anna@firma.de' => 'Anna', 'old@firma.de' => 'Old']]]]));
$this->snapshots = $this->createMock(SnapshotRepository::class);
$list = [
Snapshot::fromRow(['id' => str_repeat('f', 64), 'short_id' => 'ffffffff', 'time' => 1000, 'domains' => '{"firma.de": null, "tochter.de": null}',
'content' => $content(1500)]),
Snapshot::fromRow(['id' => str_repeat('9', 64), 'short_id' => '99999999', 'time' => 3000, 'domains' => '{"firma.de": null}', 'content' => $content(900)]),
];
$this->snapshots->method('all')->willReturn($list);
$this->snapshots->method('find')->willReturnCallback(fn(string $id): ?Snapshot => array_values(array_filter($list, fn(Snapshot $s): bool => $s->shortId === $id))[0] ?? null);
$da = self::domainAdmin();
// the recorded time decides, not the snapshot time
$this->assertSame(['ffffffff'], array_column($this->service($da)->snapshots(), 'short_id'));
$this->assertSame([], $this->service($da)->content('99999999'));
$this->assertSame([['domain' => 'firma.de', 'exists' => 1, 'mailboxes' => 2, 'missing' => 1],
['domain' => 'tochter.de', 'exists' => 1, 'mailboxes' => 1, 'missing' => 0]], $this->service()->content('ffffffff')['domains']);
$this->assertSame(['old@firma.de'], $this->service($da)->restore(self::restore(['snapshot' => 'ffffffff', 'mailboxes' => ['old@firma.de']]))->params['create_mailboxes']);
}
public function testTheSnapshotListNeedsNoContent(): void
{
// the runner writes {domain: creation time} into the row: a list of snapshots without their content is enough
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->expects($this->once())->method('all')->with(false)->willReturn([
Snapshot::fromRow(['id' => str_repeat('f', 64), 'short_id' => 'ffffffff', 'time' => 1000, 'domains' => '{"firma.de": 1500, "tochter.de": 99}']),
Snapshot::fromRow(['id' => str_repeat('9', 64), 'short_id' => '99999999', 'time' => 3000, 'domains' => '{"firma.de": 900}']),
]);
$list = $this->service(FakeContext::domainAdmin(['anna@firma.de'], ['firma.de', 'tochter.de']))->snapshots();
// firma.de of snapshot f is the current one, tochter.de is not (created 100 now); snapshot 9 holds a former firma.de
$this->assertSame([['ffffffff', ['firma.de']]], array_map(fn(array $s): array => [$s['short_id'], $s['domains']], $list));
$this->assertSame([1500, ['firma.de', 'tochter.de']], [Snapshot::fromRow(['id' => 'x', 'short_id' => 'x', 'time' => 1,
'domains' => '{"firma.de": 1500, "tochter.de": null}'])->createdOf('firma.de'), Snapshot::fromRow(['id' => 'x', 'short_id' => 'x',
'time' => 1, 'domains' => '{"firma.de": 1500, "tochter.de": null}'])->domains]);
}
public function testADeletedDomainWithoutMailboxesCanBeRestored(): void
{
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturn(Snapshot::fromRow(['id' => str_repeat('e', 64), 'short_id' => 'eeeeeeee', 'time' => 3000,
'domains' => '{"leer.de": null}', 'content' => gzencode(json_encode(['domains' => ['leer.de' => ['created' => 100, 'mailboxes' => []]]]))]));
$job = $this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'scope' => 'domain', 'domain' => 'leer.de', 'components' => ['aliases']]));
$this->assertSame([[], [], 1], [$job->params['mailboxes'], $job->params['create_mailboxes'], $job->params['create_domain']]);
}
public function testSnapshotsWithoutMailsRestoreNoMailData(): void
{
$content = gzencode(json_encode(['domains' => ['firma.de' => ['created' => 1500, 'mailboxes' => ['anna@firma.de' => 'Anna']]]]));
$snapshot = fn(string $tags): Snapshot => Snapshot::fromRow(['id' => str_repeat('e', 64), 'short_id' => 'eeeeeeee', 'time' => 3000,
'tags' => $tags, 'domains' => '{"firma.de": null}', 'content' => $content]);
$this->snapshots = $this->createMock(SnapshotRepository::class);
$this->snapshots->method('find')->willReturnOnConsecutiveCalls($snapshot('mailcow,instance:x,mode:config'),
$snapshot('mailcow,instance:x,mode:config'), $snapshot('mailcow,instance:x,mode:data'));
$this->expectUserError(['backup_components_no_mails', 'mails, imap_acl'],
fn() => $this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'components' => ['mails', 'imap_acl', 'aliases']])));
$job = $this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'components' => ['aliases', 'filters', 'password']]));
$this->assertSame(['aliases', 'filters', 'password'], $job->params['components']);
// data snapshots lack the mailcow directory only
$this->assertNotNull($this->service()->restore(self::restore(['snapshot' => 'eeeeeeee', 'mailboxes' => ['bob@firma.de']])));
$this->assertSame(['all', 'config', 'data'], [$this->snapshotList()[0]->mode(), $snapshot('mailcow,mode:config')->toArray()['mode'],
$snapshot('mode:data')->mode()]);
}
public function testTheMailComponentsOfTheUiMatch(): void
{
$script = (string)file_get_contents(__DIR__ . '/../../../../data/web/js/site/backup.js');
$this->assertSame(1, preg_match('/MAIL_COMPONENTS\s*=\s*\[([^\]]*)\]/', $script, $match), 'MAIL_COMPONENTS not found in backup.js');
preg_match_all("/'(\w+)'/", $match[1], $items);
$this->assertSame(JobService::MAIL_COMPONENTS, $items[1]);
}
public function testTheAclMapOfTheUiMatches(): void
{
$script = (string)file_get_contents(__DIR__ . '/../../../../data/web/js/site/backup.js');
$this->assertSame(1, preg_match('/COMPONENT_ACL\s*=\s*\{([^}]*)\}/', $script, $match), 'COMPONENT_ACL not found in backup.js');
preg_match_all("/(\w+)\s*:\s*'(\w+)'/", $match[1], $pairs, PREG_SET_ORDER);
$ui = array_column($pairs, 2, 1);
$php = (new \ReflectionClassConstant(JobService::class, 'COMPONENT_ACL'))->getValue();
ksort($ui);
ksort($php);
$this->assertSame($php, $ui);
}
}
@@ -0,0 +1,654 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Legacy\LegacyDomain;
use Mailcow\Backup\Model\Job;
use Mailcow\Backup\Repository\JobRepository;
use Mailcow\Backup\Service\GroupMembers;
use Mailcow\Backup\Service\RestoreService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\Sogo\Service\SogoService;
use Mailcow\Tests\Backup\Support\FakeRestoreRepository;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class RestoreServiceTest extends TestCase
{
private const ALL = ['mails', 'sogo', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'filters', 'sieve_activate',
'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'];
private const MIB = 1048576;
private FakeRestoreRepository $repo;
private JobRepository&MockObject $jobs;
private GroupMembers&MockObject $groups;
private LegacyDomain&MockObject $legacy;
/** @var array<string, string> rate limits in Redis */
private array $rateLimits = [];
private SogoService&MockObject $sogo;
private DovecotService&MockObject $dovecot;
private ?Job $job = null;
/** @var array[] memberships added through the group module: [role of the caller, group id, username] */
private array $groupUpdates = [];
private array $sogoUsers = [];
private array $authUsers = [];
protected function setUp(): void
{
$attributes = json_encode(['imap_access' => '1', 'pop3_access' => '1', 'relayhost' => '0', 'passwd_update' => '2026-01-01 10:00:00',
'mailbox_format' => 'maildir:', 'tls_enforce_in' => '0']);
$this->repo = new FakeRestoreRepository([
'domain' => [
['domain' => 'firma.de', 'aliases' => 400, 'mailboxes' => 10, 'quota' => 10240, 'maxquota' => 2048, 'active' => 1],
['domain' => 'other.de', 'aliases' => 400, 'quota' => 10240, 'maxquota' => 10240, 'active' => 1],
],
'alias_domain' => [['alias_domain' => 'firma.com', 'target_domain' => 'firma.de', 'active' => 1]],
'mailbox' => [
self::mailbox('anna@firma.de', ['attributes' => $attributes, 'quota' => 1024 * self::MIB]),
self::mailbox('bob@firma.de', ['quota' => 1024 * self::MIB]),
self::mailbox('room@firma.de', ['kind' => 'location']),
self::mailbox('carl@other.de'),
],
'alias' => [
['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 2, 'address' => 'bob@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 3, 'address' => 'info@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 4, 'address' => 'team@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1, 'group_id' => 7],
],
'mailbox_group' => [['id' => 7, 'domain' => 'firma.de', 'name' => 'Team'], ['id' => 9, 'domain' => 'firma.de', 'name' => 'Sales']],
'mail_policy' => [['id' => 3, 'domain' => 'firma.de', 'name' => 'Strict']],
'retention_rule' => [['id' => 5, 'domain' => 'firma.de', 'name' => 'Papierkorb']],
'imapsync_source' => [
['id' => 1, 'host1' => 'imap.old.de', 'port1' => 993, 'scope' => 'all'],
['id' => 2, 'host1' => 'imap.legacy.de', 'port1' => 143, 'scope' => 'domain'],
],
'imapsync_source_domain' => [['source_id' => 2, 'domain' => 'firma.de']],
'relayhosts' => [['id' => 1, 'hostname' => 'relay.example.org']],
'user_acl' => [['username' => 'anna@firma.de', 'spam_alias' => 1, 'syncjobs' => 0, 'pushover' => 1]],
]);
$this->jobs = $this->createMock(JobRepository::class);
$this->jobs->method('find')->willReturnCallback(fn(int $id): ?Job => ($this->job !== null && $this->job->id === $id) ? $this->job : null);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('add')->willReturnCallback(function (Context $context, int $id, string $username): void {
$this->groupUpdates[] = [$context->role, $id, $username];
});
$this->legacy = $this->createMock(LegacyDomain::class);
$this->legacy->method('rateLimits')->willReturnCallback(fn(array $objects): array => array_intersect_key($this->rateLimits, array_flip($objects)));
$this->legacy->method('setRateLimit')->willReturnCallback(function (string $object, string $value): void {
$this->rateLimits[$object] = $value;
});
$this->sogo = $this->createMock(SogoService::class);
$this->sogo->method('refreshStaticView')->willReturnCallback(function (array $users): void {
$this->sogoUsers = $users;
});
$this->dovecot = $this->createMock(DovecotService::class);
$this->dovecot->method('flushAuthCache')->willReturnCallback(function (array $users): void {
$this->authUsers = $users;
});
$this->job(self::ALL);
}
private static function mailbox(string $username, array $values = []): array
{
[$local, $domain] = explode('@', $username);
return $values + ['username' => $username, 'password' => 'HASH-NOW', 'name' => $local, 'quota' => 0, 'local_part' => $local,
'domain' => $domain, 'attributes' => '{}', 'custom_attributes' => '{}', 'kind' => '', 'authsource' => 'mailcow', 'active' => 1];
}
private function job(array $components, string $role = 'admin', array $acl = [], string $state = 'running', array $params = []): void
{
$this->job = new Job('restore');
$this->job->id = 12;
$this->job->state = $state;
$this->job->domain = 'firma.de';
$this->job->params = $params + ['snapshot' => 'abc', 'scope' => 'mailbox', 'domain' => 'firma.de', 'mailboxes' => ['anna@firma.de'],
'components' => $components, 'mail_target' => 'folder', 'requested_role' => $role] + ($role === 'admin' ? [] : ['acl' => $acl]);
}
/** A domain admin account of firma.de with these da_acl flags (all others 0) */
private function domainAdmin(string $username, array $acl): void
{
$this->repo->tables['admin'][] = ['username' => $username, 'superadmin' => 0, 'active' => 1];
$this->repo->tables['domain_admins'][] = ['id' => 1, 'username' => $username, 'domain' => 'firma.de', 'active' => 1];
$this->repo->tables['da_acl'][] = ['username' => $username] + $acl + array_fill_keys(array_diff($this->repo->columns('da_acl'), ['username']), 0);
}
private function service(bool $externalDisabled = false): RestoreService
{
return new RestoreService($this->repo, $this->jobs, $this->groups, $this->sogo, $this->dovecot, $this->legacy, $externalDisabled);
}
/** The mailbox section of anna's export, as the runner sends it */
private static function backup(array $override = []): array
{
$attributes = json_encode(['imap_access' => '0', 'pop3_access' => '1', 'relayhost' => '1', 'passwd_update' => '2025-05-05 05:05:05',
'mailbox_format' => 'sdbox:', 'tls_enforce_in' => '1']);
return $override + [
'mailbox' => self::mailbox('anna@firma.de', ['password' => 'HASH-OLD', 'quota' => 2048 * self::MIB, 'attributes' => $attributes,
'custom_attributes' => '{"dept": "IT"}']) + ['tags' => ['vip', 'it']],
'user_acl' => ['username' => 'anna@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 1, 'gone_column' => 1],
'pushover' => ['username' => 'anna@firma.de', 'key' => 'K', 'token' => 'T', 'attributes' => '{}', 'title' => 'Mail', 'active' => 1],
'ratelimit' => '20 / 1h',
'aliases' => [
['id' => 30, 'address' => 'info@firma.de', 'goto' => 'bob@firma.de,anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
['id' => 31, 'address' => 'sales@firma.com', 'goto' => 'anna@firma.de,gone@firma.de,partner@example.org,room@firma.de',
'domain' => 'firma.com', 'active' => 1, 'public_comment' => 'Vertrieb', 'old_column' => 'x'],
['id' => 32, 'address' => 'team@firma.de', 'goto' => 'anna@firma.de,bob@firma.de', 'domain' => 'firma.de', 'group_id' => 7],
['id' => 33, 'address' => 'help@other.de', 'goto' => 'anna@firma.de', 'domain' => 'other.de', 'active' => 1],
],
'sender_acl' => [
['id' => 1, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'info@firma.de', 'external' => 0, 'group_id' => null],
['id' => 2, 'logged_in_as' => 'anna@firma.de', 'send_as' => '@firma.com', 'external' => 0, 'group_id' => null],
['id' => 3, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'gone@firma.de', 'external' => 0, 'group_id' => null],
['id' => 4, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'team@firma.de', 'external' => 0, 'group_id' => 7],
['id' => 5, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'anna@private.example', 'external' => 1, 'group_id' => null],
],
'sender_acl_granted' => [
['id' => 6, 'logged_in_as' => 'bob@firma.de', 'send_as' => 'anna@firma.de', 'external' => 0, 'group_id' => null],
['id' => 7, 'logged_in_as' => 'ghost@firma.de', 'send_as' => 'anna@firma.de', 'external' => 0, 'group_id' => null],
],
'groups' => [
['id' => 7, 'name' => 'Team', 'domain' => 'firma.de', 'source' => 'manual'],
['id' => 99, 'name' => 'Sales', 'domain' => 'firma.de', 'source' => 'manual'],
['id' => 98, 'name' => 'Gone', 'domain' => 'firma.de', 'source' => 'manual'],
['id' => 97, 'name' => 'Synced', 'domain' => 'firma.de', 'source' => 'idp'],
],
'mail_policies' => [['id' => 3, 'name' => 'Strict', 'domain' => 'firma.de']],
'retention_rules' => [['id' => 77, 'name' => 'Papierkorb', 'domain' => 'firma.de'], ['id' => 78, 'name' => 'Weg', 'domain' => 'firma.de']],
'sieve_filters' => [
['id' => 4, 'username' => 'anna@firma.de', 'script_desc' => 'Spam', 'script_name' => 'active', 'script_data' => 'discard;', 'filter_type' => 'prefilter'],
['id' => 5, 'username' => 'anna@firma.de', 'script_desc' => 'Old', 'script_name' => 'inactive', 'script_data' => 'keep;', 'filter_type' => 'postfilter'],
],
'filterconf' => [
['prefid' => 1, 'object' => 'anna@firma.de', 'option' => 'whitelist_from', 'value' => 'boss@example.org'],
['prefid' => 2, 'object' => 'anna@firma.de', 'option' => 'highspamlevel', 'value' => '12'],
],
'spamalias' => [
['address' => 'abc123@firma.de', 'goto' => 'anna@firma.de', 'description' => 'Shop', 'validity' => time() + 3600, 'permanent' => 0],
['address' => 'old999@firma.de', 'goto' => 'anna@firma.de', 'description' => 'Old', 'validity' => time() - 3600, 'permanent' => 0],
['address' => 'bob@firma.de', 'goto' => 'anna@firma.de', 'description' => 'Taken', 'validity' => 0, 'permanent' => 1],
['address' => 'xyz@other.de', 'goto' => 'anna@firma.de', 'description' => 'Foreign', 'validity' => 0, 'permanent' => 1],
],
'bcc_maps' => [['id' => 1, 'local_dest' => 'anna@firma.de', 'bcc_dest' => 'archive@example.org', 'domain' => 'firma.de', 'type' => 'rcpt', 'active' => 1]],
'imapsync' => [
['id' => 40, 'user2' => 'anna@firma.de', 'source_id' => 1, 'user1' => 'anna@old.de', 'password1' => 'pw', 'maxage' => 30,
'source_host1' => 'imap.old.de', 'source_port1' => 993, 'is_running' => 1, 'last_run' => '2026-01-01 00:00:00', 'prio' => 9, 'active' => 1],
['id' => 41, 'user2' => 'anna@firma.de', 'source_id' => 55, 'user1' => 'anna@legacy.de', 'password1' => 'pw',
'source_host1' => 'imap.legacy.de', 'source_port1' => 143, 'active' => 1],
['id' => 42, 'user2' => 'anna@firma.de', 'source_id' => 56, 'user1' => 'anna@gone.de', 'password1' => 'pw',
'source_host1' => 'imap.gone.de', 'source_port1' => 993, 'active' => 1],
],
'app_passwd' => [['id' => 8, 'name' => 'Phone', 'mailbox' => 'anna@firma.de', 'domain' => 'firma.de', 'password' => 'APPHASH', 'imap_access' => 1, 'active' => 1]],
'tfa' => [['id' => 6, 'key_id' => 'Phone', 'username' => 'anna@firma.de', 'authmech' => 'totp', 'secret' => 'SECRET', 'active' => 1]],
'fido2' => [['username' => 'anna@firma.de', 'friendlyName' => 'Key', 'rpId' => 'mail.firma.de', 'credentialPublicKey' => 'pk',
'credentialId' => ['base64' => base64_encode("\x00\xff\x10")], 'AAGUID' => ['base64' => base64_encode("\xfe\x01")], 'active' => 1]],
];
}
private function merge(array $data = [], string $username = 'anna@firma.de'): array
{
return $this->service()->merge(12, $username, $data ?: self::backup());
}
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());
}
}
private function alias(string $address): ?array
{
return $this->repo->rows('alias', ['address' => $address])[0] ?? null;
}
public function testMissingThingsAreAddedAndNothingIsDeleted(): void
{
$this->repo->tables['sieve_filters'][] = ['id' => 1, 'username' => 'anna@firma.de', 'script_desc' => 'Mine', 'script_name' => 'active',
'script_data' => 'stop;', 'filter_type' => 'prefilter'];
$this->repo->tables['filterconf'][] = ['prefid' => 9, 'object' => 'anna@firma.de', 'option' => 'highspamlevel', 'value' => '20'];
$result = $this->merge();
$this->assertSame(['aliases', 'sender_acl', 'mail_policies', 'retention_rules', 'filters', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa',
'settings', 'password', 'groups'], array_keys($result));
// aliases: anna back into info@, sales@ created with the gotos that exist now, group address and foreign domain left alone
$this->assertSame('bob@firma.de,anna@firma.de', $this->alias('info@firma.de')['goto']);
$sales = $this->alias('sales@firma.com');
$this->assertSame(['anna@firma.de,partner@example.org', 'firma.com', 'Vertrieb', null], [$sales['goto'], $sales['domain'], $sales['public_comment'], $sales['group_id']]);
$this->assertSame('bob@firma.de', $this->alias('team@firma.de')['goto']);
$this->assertSame('anna@firma.de', $this->alias('help@other.de')['goto']);
$this->assertSame(['added' => 3, 'skipped' => 1], array_slice($result['aliases'], 0, 2));
$this->assertSame(['sales@firma.com: goto gone@firma.de dropped (no mailbox or alias)', 'sales@firma.com: goto room@firma.de dropped (no mailbox or alias)'],
$result['aliases']['errors']);
// sender_acl: both directions, only for what exists, group rows never
$this->assertEqualsCanonicalizing([['anna@firma.de', 'info@firma.de', 0], ['anna@firma.de', '@firma.com', 0], ['anna@firma.de', 'anna@private.example', 1],
['bob@firma.de', 'anna@firma.de', 0]], array_map(fn(array $r): array => [$r['logged_in_as'], $r['send_as'], $r['external']], $this->repo->tables['sender_acl']));
$this->assertSame(['gone@firma.de: address does not exist', 'ghost@firma.de: mailbox does not exist'], $result['sender_acl']['errors']);
// policies by id, else by name
$this->assertSame([3], array_column($this->repo->tables['mail_policy_member'], 'policy_id'));
$this->assertSame([5], array_column($this->repo->tables['retention_rule_member'], 'rule_id'));
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => []], $result['mail_policies']);
$this->assertSame(['Weg: does not exist'], $result['retention_rules']['errors']);
// filters: the mailbox has an active prefilter, so the restored one stays inactive
$this->assertSame([['Mine', 'active'], ['Spam', 'inactive'], ['Old', 'inactive']],
array_map(fn(array $f): array => [$f['script_desc'], $f['script_name']], $this->repo->tables['sieve_filters']));
// spam: the existing level stays, the list entry and the valid free spam alias are added
$this->assertSame([['highspamlevel', '20'], ['whitelist_from', 'boss@example.org']],
array_map(fn(array $f): array => [$f['option'], $f['value']], $this->repo->tables['filterconf']));
$this->assertSame(['abc123@firma.de'], array_column($this->repo->tables['spamalias'], 'address'));
// spam aliases only in the mailbox's domain and its alias domains, like the legacy form
$this->assertSame(['added' => 2, 'skipped' => 2, 'errors' => ['bob@firma.de: address is in use (mailbox)', 'xyz@other.de: not a domain of the mailbox']],
$result['spam']);
$this->assertSame(['archive@example.org'], array_column($this->repo->tables['bcc_maps'], 'bcc_dest'));
// sync jobs: inactive, source by id and host, else another usable source with the same host
$this->assertSame([['anna@old.de', 1, 0, null, null, 0], ['anna@legacy.de', 2, 0, null, null, 0]],
array_map(fn(array $j): array => [$j['user1'], $j['source_id'], $j['active'], $j['last_run'], $j['prio'], $j['is_running'] ?? 0], $this->repo->tables['imapsync']));
$this->assertSame(['anna@gone.de (imap.gone.de:993): sync source does not exist'], $result['syncjobs']['errors']);
$this->assertSame([['Phone', 'APPHASH', 'firma.de']], array_map(fn(array $a): array => [$a['name'], $a['password'], $a['domain']], $this->repo->tables['app_passwd']));
$this->assertSame('SECRET', $this->repo->tables['tfa'][0]['secret']);
$this->assertSame(["\x00\xff\x10", "\xfe\x01"], [$this->repo->tables['fido2'][0]['credentialId'], $this->repo->tables['fido2'][0]['AAGUID']]);
$this->assertSame(['added' => 2, 'skipped' => 0, 'errors' => []], $result['tfa']);
// groups through the group module, by id or name; IdP memberships are the IdP sync's
$this->assertSame([['admin', 7, 'anna@firma.de'], ['admin', 9, 'anna@firma.de']], $this->groupUpdates);
$this->assertSame(['added' => 2, 'skipped' => 1, 'errors' => ['Gone: does not exist']], $result['groups']);
// caches of the touched mailboxes
$this->assertEqualsCanonicalizing(['anna@firma.de', 'bob@firma.de'], $this->sogoUsers);
$this->assertSame(['anna@firma.de'], $this->authUsers);
}
public function testSettingsAndPasswordOverwrite(): void
{
$this->job(['settings', 'password']);
$this->repo->tables['tags_mailbox'][] = ['tag_name' => 'vip', 'username' => 'anna@firma.de'];
$result = $this->merge();
$anna = $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0];
$attributes = json_decode($anna['attributes'], true);
// overwritten except storage format; the password component sets passwd_update of the backup
$this->assertSame(['imap_access' => '0', 'pop3_access' => '1', 'relayhost' => '1', 'passwd_update' => '2025-05-05 05:05:05',
'mailbox_format' => 'maildir:', 'tls_enforce_in' => '1'], $attributes);
$this->assertSame([2048 * self::MIB, 'HASH-OLD', ['dept' => 'IT']], [$anna['quota'], $anna['password'], json_decode($anna['custom_attributes'], true)]);
$this->assertSame(['vip', 'it'], array_column($this->repo->tables['tags_mailbox'], 'tag_name'));
$this->assertSame(['username' => 'anna@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 1], $this->repo->tables['user_acl'][0]);
$this->assertSame('Mail', $this->repo->tables['pushover'][0]['title']);
$this->assertSame(['anna@firma.de' => '20 / 1h'], $this->rateLimits);
// quota, 3 attributes, custom attributes, a tag, 2 user ACL values, Pushover, rate limit
$this->assertSame(['added' => 10, 'skipped' => 3, 'errors' => []], $result['settings']);
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => []], $result['password']);
$this->assertSame(['anna@firma.de'], $this->authUsers);
}
public function testQuotaStaysWithinTheDomainLimits(): void
{
$this->job(['settings']);
$tooBig = self::backup();
$tooBig['mailbox']['quota'] = 4096 * self::MIB;
$this->assertSame(['quota: exceeds the maximum quota of a mailbox in the domain'], $this->merge($tooBig)['settings']['errors']);
$this->repo->update('mailbox', ['username' => 'bob@firma.de'], ['quota' => 9000 * self::MIB]);
$this->assertSame(['quota: exceeds the quota left in the domain'], $this->merge()['settings']['errors']);
$this->assertSame(1024 * self::MIB, $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0]['quota']);
}
public function testDomainAdminRestoresKeepToHisAcl(): void
{
$this->job(['aliases', 'sender_acl', 'settings', 'mail_policies', 'retention_rules', 'groups'], 'domainadmin', ['alias_external_goto' => 0,
'extend_sender_acl' => 0, 'protocol_access' => 0, 'mailbox_relayhost' => 1, 'unlimited_quota' => 0, 'pushover' => 0, 'mail_policies' => 1,
'retention' => 0, 'ratelimit' => 0, 'tls_policy' => 0, 'mailbox_groups' => 1]);
$this->job->requestedBy = 'da@firma.de';
$this->domainAdmin('da@firma.de', ['mailbox_relayhost' => 1, 'mail_policies' => 1, 'mailbox_groups' => 1]);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('add')->willReturnCallback(function (Context $context, int $id): void {
$this->groupUpdates[] = [$context->role, $context->username, $context->hasAcl('mailbox_groups')];
// the group module refuses groups that are read-only for him
if ($id === 9) {
throw new AccessDenied();
}
});
$data = self::backup();
$data['mailbox']['quota'] = 0;
$data['sender_acl'][] = ['id' => 9, 'logged_in_as' => 'anna@firma.de', 'send_as' => '*', 'external' => 0, 'group_id' => null];
$data['sender_acl'][] = ['id' => 10, 'logged_in_as' => 'anna@firma.de', 'send_as' => 'carl@other.de', 'external' => 0, 'group_id' => null];
$result = $this->merge($data);
// the alias of another domain is not his; external gotos are not allowed for him
$this->assertSame('anna@firma.de', $this->alias('sales@firma.com')['goto']);
$this->assertContains('help@other.de: outside the domain of the restore', $result['aliases']['errors']);
$this->assertContains('sales@firma.com: goto partner@example.org dropped (external gotos are not allowed)', $result['aliases']['errors']);
$this->assertSame(['gone@firma.de: address does not exist', 'anna@private.example: not allowed', '*: only admins grant *',
'carl@other.de: outside the domain of the restore', 'ghost@firma.de: mailbox does not exist'], $result['sender_acl']['errors']);
// user ACLs he does not have himself keep their value (syncjobs; pushover is equal)
$this->assertSame(['quota: unlimited quota not allowed', 'imap_access: not allowed', 'tls_enforce_in: not allowed', 'user ACL syncjobs: not allowed',
'pushover: not allowed', 'rate limit: not allowed'], $result['settings']['errors']);
$this->assertSame([0, 0, 1], [$this->repo->tables['user_acl'][0]['spam_alias'], $this->repo->tables['user_acl'][0]['syncjobs'],
$this->repo->tables['user_acl'][0]['pushover']]);
// groups as the domain admin with his ACL of the request
$this->assertSame([['domainadmin', 'da@firma.de', true], ['domainadmin', 'da@firma.de', true]], $this->groupUpdates);
$this->assertSame(['added' => 1, 'skipped' => 1, 'errors' => ['Sales: not allowed', 'Gone: does not exist']], $result['groups']);
$anna = $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0];
$this->assertSame(['1', '1'], [json_decode($anna['attributes'], true)['imap_access'], json_decode($anna['attributes'], true)['relayhost']]);
$this->assertSame([], $this->repo->tables['pushover']);
$this->assertSame(['not allowed'], $result['retention_rules']['errors']);
$this->assertCount(1, $this->repo->tables['mail_policy_member']);
$this->assertSame([], $this->repo->tables['retention_rule_member']);
}
public function testExternalGotosCanBeDisabledGlobally(): void
{
$this->job(['aliases']);
$result = $this->service(true)->merge(12, 'anna@firma.de', self::backup());
$this->assertSame('anna@firma.de', $this->alias('sales@firma.com')['goto']);
$this->assertContains('sales@firma.com: goto partner@example.org dropped (external gotos are not allowed)', $result['aliases']['errors']);
}
public function testASecondMergeAddsNothing(): void
{
$this->merge();
$this->groupUpdates = [];
$this->repo->tables['mailbox_group_member'] = [['group_id' => 7, 'username' => 'anna@firma.de', 'source' => 'manual'],
['group_id' => 9, 'username' => 'anna@firma.de', 'source' => 'manual']];
$tables = $this->repo->tables;
$result = $this->merge();
$this->assertSame($tables, $this->repo->tables);
$this->assertSame([], $this->groupUpdates);
foreach ($result as $component => $counts) {
$this->assertSame(0, $counts['added'], $component);
}
}
public function testOnlyTheComponentsOfTheJobAreMerged(): void
{
$this->job(['mails', 'sogo', 'bcc']);
$this->assertSame(['bcc'], array_keys($this->merge()));
$this->assertSame([], $this->repo->tables['tfa']);
$this->assertSame([], $this->groupUpdates);
}
public function testTheAliasesOfADomainRestoreComeInParts(): void
{
$this->job(['aliases'], 'admin', [], 'running', ['scope' => 'domain', 'mailboxes' => ['anna@firma.de', 'bob@firma.de']]);
$domainAliases = [
['id' => 1, 'address' => 'anna@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
['id' => 50, 'address' => '@firma.com', 'goto' => 'bob@firma.de', 'domain' => 'firma.com', 'active' => 1],
['id' => 51, 'address' => 'news@firma.de', 'goto' => 'null@localhost', 'domain' => 'firma.de', 'active' => 1],
// existing aliases get their mailboxes back with the mailboxes' merge
['id' => 3, 'address' => 'info@firma.de', 'goto' => 'bob@firma.de,anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
];
$result = $this->service()->mergeAliases(12, $domainAliases);
$this->assertSame(['aliases' => ['added' => 2, 'skipped' => 1, 'errors' => []]], $result);
$this->assertSame(['bob@firma.de', 'null@localhost', 'bob@firma.de'], [$this->alias('@firma.com')['goto'], $this->alias('news@firma.de')['goto'],
$this->alias('info@firma.de')['goto']]);
$this->assertSame(['bob@firma.de'], $this->sogoUsers);
// mailboxes the restore creates count as existing gotos
$this->job(['aliases'], 'admin', [], 'running', ['scope' => 'domain', 'mailboxes' => ['anna@firma.de', 'dora@firma.de'],
'create_mailboxes' => ['dora@firma.de']]);
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => ['x@firma.de: goto gone@firma.de dropped (no mailbox or alias)']],
$this->service()->mergeAliases(12, [['address' => 'x@firma.de', 'goto' => 'dora@firma.de,gone@firma.de', 'domain' => 'firma.de']])['aliases']);
$this->assertSame('dora@firma.de', $this->alias('x@firma.de')['goto']);
$this->expectUserError(['backup_entries_exceeded', 500], fn() => $this->service()->mergeAliases(12, array_fill(0, 501, [])));
$this->expectUserError('backup_export_invalid', fn() => $this->service()->mergeAliases(12, 'x'));
// only for domain restores with aliases
$this->job(['aliases']);
$this->expectUserError('access_denied', fn() => $this->service()->mergeAliases(12, $domainAliases));
$this->job(['mails'], 'admin', [], 'running', ['scope' => 'domain']);
$this->expectUserError('access_denied', fn() => $this->service()->mergeAliases(12, $domainAliases));
}
public function testADeletedMailboxIsCreatedFromTheBackup(): void
{
$this->job(['aliases', 'settings'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup();
$data['mailbox'] = self::mailbox('dora@firma.de', ['password' => 'HASH-DORA', 'quota' => 512 * self::MIB, 'name' => 'Dora',
'attributes' => '{"imap_access": "1"}', 'created' => '2020-01-01 00:00:00']) + ['tags' => ['vip']];
$data['user_acl'] = ['username' => 'dora@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 0];
$data['aliases'] = [['address' => 'info@firma.de', 'goto' => 'bob@firma.de,dora@firma.de', 'domain' => 'firma.de']];
$result = $this->merge($data, 'dora@firma.de');
$this->assertSame(['added' => 1, 'skipped' => 0, 'errors' => [], 'created' => 1], $result['mailbox']);
$dora = $this->repo->rows('mailbox', ['username' => 'dora@firma.de'])[0];
$this->assertSame(['HASH-DORA', 'Dora', 512 * self::MIB, 'dora', 'firma.de', '2020-01-01 00:00:00'], [$dora['password'], $dora['name'], $dora['quota'],
$dora['local_part'], $dora['domain'], $dora['created']]);
// the side effects of the legacy mailbox add
$this->assertSame('dora@firma.de', $this->alias('dora@firma.de')['goto']);
$this->assertSame([['username' => 'dora@firma.de', 'bytes' => 0, 'messages' => 0]], $this->repo->tables['quota2']);
$this->assertSame([0, 1], [$this->repo->rows('user_acl', ['username' => 'dora@firma.de'])[0]['spam_alias'],
$this->repo->rows('user_acl', ['username' => 'dora@firma.de'])[0]['syncjobs']]);
$this->assertSame(['vip'], array_column($this->repo->rows('tags_mailbox', ['username' => 'dora@firma.de']), 'tag_name'));
// then the components as for an existing mailbox
$this->assertSame('bob@firma.de,dora@firma.de', $this->alias('info@firma.de')['goto']);
// quota, attributes, user ACL and tags came with the mailbox: only Pushover and the rate limit are new
$this->assertSame(2, $result['settings']['added']);
$this->assertContains('dora@firma.de', $this->sogoUsers);
$this->assertContains('dora@firma.de', $this->authUsers);
// a second request finds it
$this->assertSame(['added' => 0, 'skipped' => 1, 'errors' => [], 'created' => 0], $this->merge($data, 'dora@firma.de')['mailbox']);
}
public function testACreatedMailboxGetsItsForwardingBack(): void
{
$this->job(['aliases'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup(['aliases' => []]);
$data['mailbox'] = self::mailbox('dora@firma.de');
// forwarding with a copy, one goto no longer valid
$data['own_alias'] = ['id' => 60, 'address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example,gone@firma.de', 'domain' => 'firma.de'];
$result = $this->merge($data, 'dora@firma.de');
$this->assertSame(1, $result['mailbox']['created']);
$this->assertSame('dora@firma.de,fwd@ext.example', $this->alias('dora@firma.de')['goto']);
$this->assertSame(['dora@firma.de: goto gone@firma.de dropped (no mailbox or alias)'], $result['aliases']['errors']);
// forwarding without a copy
$this->repo->tables['mailbox'] = array_values(array_filter($this->repo->tables['mailbox'], fn(array $row): bool => $row['username'] !== 'dora@firma.de'));
$this->repo->tables['alias'] = array_values(array_filter($this->repo->tables['alias'], fn(array $row): bool => $row['address'] !== 'dora@firma.de'));
$data['own_alias']['goto'] = 'fwd@ext.example';
$this->merge($data, 'dora@firma.de');
$this->assertSame('fwd@ext.example', $this->alias('dora@firma.de')['goto']);
// an existing mailbox keeps its own alias as it is now
$this->job(['aliases']);
$this->merge(self::backup(['aliases' => [], 'own_alias' => ['address' => 'anna@firma.de', 'goto' => 'anna@firma.de,fwd@ext.example']]));
$this->assertSame('anna@firma.de', $this->alias('anna@firma.de')['goto']);
}
public function testTheAliasLeftByADeletedForwardingMailboxIsTakenBack(): void
{
// deleting dora (forwarding with a copy) left dora@ -> fwd@ext.example
$this->repo->tables['alias'][] = ['id' => 70, 'address' => 'dora@firma.de', 'goto' => 'fwd@ext.example', 'domain' => 'firma.de', 'active' => 1];
$this->job(['aliases'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup(['aliases' => []]);
$data['mailbox'] = self::mailbox('dora@firma.de');
$data['own_alias'] = ['address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example', 'domain' => 'firma.de'];
$this->assertSame(1, $this->merge($data, 'dora@firma.de')['mailbox']['created']);
$this->assertSame([70, 'dora@firma.de,fwd@ext.example'], [$this->alias('dora@firma.de')['id'], $this->alias('dora@firma.de')['goto']]);
// an alias with other targets (added since) still blocks
$this->repo->tables['mailbox'] = array_values(array_filter($this->repo->tables['mailbox'], fn(array $row): bool => $row['username'] !== 'dora@firma.de'));
$this->repo->update('alias', ['id' => 70], ['goto' => 'bob@firma.de']);
$this->assertSame(['the address is in use (alias)'], $this->merge($data, 'dora@firma.de')['mailbox']['errors']);
}
public function testTheOwnAliasOfADeletedMailboxDoesNotBlockIt(): void
{
// a domain restore: dora is created with her mailbox, gone@ stays deleted; neither becomes a plain alias
$this->job(['aliases'], 'admin', [], 'running', ['scope' => 'domain', 'mailboxes' => ['anna@firma.de', 'dora@firma.de'],
'create_mailboxes' => ['dora@firma.de']]);
$result = $this->service()->mergeAliases(12, [
['address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example', 'domain' => 'firma.de', 'active' => 1],
['address' => 'gone@firma.de', 'goto' => 'gone@firma.de,anna@firma.de', 'domain' => 'firma.de', 'active' => 1],
]);
$this->assertSame(['added' => 0, 'skipped' => 1, 'errors' => []], $result['aliases']);
$this->assertSame([null, null], [$this->alias('dora@firma.de'), $this->alias('gone@firma.de')]);
$data = self::backup(['aliases' => []]);
$data['mailbox'] = self::mailbox('dora@firma.de');
$data['own_alias'] = ['address' => 'dora@firma.de', 'goto' => 'dora@firma.de,fwd@ext.example', 'domain' => 'firma.de'];
$this->assertSame(1, $this->merge($data, 'dora@firma.de')['mailbox']['created']);
$this->assertSame('dora@firma.de,fwd@ext.example', $this->alias('dora@firma.de')['goto']);
// a mailbox restore: the own alias of a deleted mailbox naming anna is not created as an alias
$this->job(['aliases']);
$this->merge(self::backup(['aliases' => [['address' => 'gone@firma.de', 'goto' => 'gone@firma.de,anna@firma.de', 'domain' => 'firma.de']]]));
$this->assertNull($this->alias('gone@firma.de'));
}
public function testTheAliasLimitCountsTheAliasesOfTheRequest(): void
{
$this->job(['aliases']);
// info@ and team@ use 2 of 3
$this->repo->update('domain', ['domain' => 'firma.de'], ['aliases' => 3]);
$result = $this->merge(self::backup(['aliases' => [
['address' => 'one@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
['address' => 'two@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
]]));
$this->assertSame([1, ['two@firma.de: alias limit of the domain reached']], [$result['aliases']['added'], $result['aliases']['errors']]);
}
public function testAMailboxIsCreatedWithinTheDomainLimits(): void
{
$this->job(['aliases'], 'admin', [], 'running', ['mailboxes' => ['dora@firma.de', 'eve@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$data = self::backup();
$data['mailbox'] = self::mailbox('dora@firma.de', ['quota' => 4096 * self::MIB]);
$this->assertSame(['mailbox' => ['added' => 0, 'skipped' => 0, 'errors' => ['quota: exceeds the maximum quota of a mailbox in the domain'], 'created' => 0]],
$this->merge($data, 'dora@firma.de'));
$data['mailbox']['quota'] = 0;
$this->repo->update('domain', ['domain' => 'firma.de'], ['mailboxes' => 2]);
$this->assertSame(['mailbox limit of the domain reached'], $this->merge($data, 'dora@firma.de')['mailbox']['errors']);
$this->repo->update('domain', ['domain' => 'firma.de'], ['mailboxes' => 10]);
$this->repo->tables['alias'][] = ['id' => 60, 'address' => 'dora@firma.de', 'goto' => 'bob@firma.de', 'domain' => 'firma.de', 'active' => 1];
$this->assertSame(['the address is in use (alias)'], $this->merge($data, 'dora@firma.de')['mailbox']['errors']);
$this->assertSame([], $this->repo->rows('mailbox', ['username' => 'dora@firma.de']));
// a mailbox missing now that was not missing when the restore was requested is not created
$eve = self::backup();
$eve['mailbox'] = self::mailbox('eve@firma.de');
$this->expectUserError(['backup_mailbox_not_found', 'eve@firma.de'], fn() => $this->merge($eve, 'eve@firma.de'));
}
public function testARestoreStopsWhenTheDomainAdminLostHisRights(): void
{
$revoked = ['mailbox' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked'], 'created' => 0]];
$this->job(['aliases', 'groups'], 'domainadmin', ['mailbox_groups' => 1, 'filters' => 0]);
$this->job->requestedBy = 'da@firma.de';
// no account
$this->assertSame($revoked, $this->merge());
// an ACL of the request taken away
$this->domainAdmin('da@firma.de', ['mailbox_groups' => 0]);
$this->assertSame($revoked, $this->merge());
$this->repo->update('da_acl', ['username' => 'da@firma.de'], ['mailbox_groups' => 1]);
$this->assertSame(['aliases', 'groups'], array_keys($this->merge()));
// no longer administers the domain, or the account is disabled
$this->repo->update('domain_admins', ['username' => 'da@firma.de'], ['active' => 0]);
$this->assertSame($revoked, $this->merge());
$this->repo->update('domain_admins', ['username' => 'da@firma.de'], ['active' => 1]);
$this->repo->update('admin', ['username' => 'da@firma.de'], ['active' => 0]);
$this->assertSame($revoked, $this->merge());
$this->job(['aliases'], 'domainadmin', ['mailbox_groups' => 1], 'running', ['scope' => 'domain']);
$this->job->requestedBy = 'da@firma.de';
$this->assertSame(['aliases' => ['added' => 0, 'skipped' => 0, 'errors' => ['permission revoked']]], $this->service()->mergeAliases(12, []));
// only the merge with all rights reached the group module
$this->assertCount(2, $this->groupUpdates);
}
public function testAMailboxCreatedByADomainAdminGetsDefaultsForAclsHeLacks(): void
{
$this->job(['aliases'], 'domainadmin', ['app_passwds' => 1], 'running', ['mailboxes' => ['dora@firma.de'], 'create_mailboxes' => ['dora@firma.de']]);
$this->job->requestedBy = 'da@firma.de';
$this->domainAdmin('da@firma.de', ['app_passwds' => 1]);
$data = self::backup();
$data['mailbox'] = self::mailbox('dora@firma.de', ['quota' => 512 * self::MIB]);
$data['user_acl'] = ['username' => 'dora@firma.de', 'spam_alias' => 0, 'syncjobs' => 1, 'pushover' => 1, 'app_passwds' => 1];
$this->assertSame(1, $this->merge($data, 'dora@firma.de')['mailbox']['created']);
// null: the column's default in the table
$this->assertSame(['username' => 'dora@firma.de', 'spam_alias' => 0, 'syncjobs' => null, 'pushover' => null, 'quarantine' => null, 'app_passwds' => 1],
$this->repo->rows('user_acl', ['username' => 'dora@firma.de'])[0]);
}
public function testARetriedRequestWaitsForTheFirst(): void
{
$this->repo->busyLocks = ['12:anna@firma.de'];
$this->expectUserError('backup_job_busy', fn() => $this->merge());
$this->assertSame([], $this->groupUpdates);
}
public function testAliasLimitAndAddressesInUse(): void
{
$this->job(['aliases']);
$this->repo->tables['spamalias'][] = ['address' => 'shop@firma.de', 'goto' => 'bob@firma.de', 'validity' => time() + 60, 'permanent' => 0];
$data = self::backup(['aliases' => [
['address' => 'shop@firma.de', 'goto' => 'anna@firma.de', 'domain' => 'firma.de'],
['address' => 'bob@firma.com', 'goto' => 'anna@firma.de', 'domain' => 'firma.com'],
['address' => 'x@unknown.de', 'goto' => 'anna@firma.de', 'domain' => 'unknown.de'],
]]);
$this->assertSame(['shop@firma.de: address is in use (spamalias)', 'bob@firma.com: address is in use (mailbox)', 'x@unknown.de: domain does not exist'],
$this->merge($data)['aliases']['errors']);
$this->repo->update('domain', ['domain' => 'firma.de'], ['aliases' => 2]);
$this->assertSame(['sales@firma.com: alias limit of the domain reached'], array_slice($this->merge()['aliases']['errors'], 0, 1));
}
public function testADatabaseErrorDropsOnlyItsComponent(): void
{
$this->job(['bcc', 'tfa', 'app_passwords']);
$this->repo->failingTables = ['fido2'];
$result = $this->merge();
$this->assertSame(['added' => 0, 'skipped' => 0, 'errors' => ['database error 23000']], $result['tfa']);
// the 2FA entry written before the failing FIDO2 key was rolled back with it
$this->assertSame([[], []], [$this->repo->tables['tfa'], $this->repo->tables['fido2']]);
$this->assertSame(1, $result['bcc']['added']);
$this->assertCount(1, $this->repo->tables['app_passwd']);
}
public function testPasswordOnlyForMailcowLogins(): void
{
$this->job(['password']);
$this->repo->update('mailbox', ['username' => 'anna@firma.de'], ['authsource' => 'keycloak']);
$this->assertSame(['the mailbox logs in through an identity provider'], $this->merge()['password']['errors']);
$this->assertSame('HASH-NOW', $this->repo->rows('mailbox', ['username' => 'anna@firma.de'])[0]['password']);
$this->assertSame([], $this->authUsers);
}
public function testGroupErrorsAreReported(): void
{
$this->job(['groups']);
$this->groups = $this->createMock(GroupMembers::class);
$this->groups->method('add')->willThrowException(new UserError('group_member_invalid', ['anna@firma.de']));
$this->assertSame(['Team: group_member_invalid', 'Sales: group_member_invalid', 'Gone: does not exist'], $this->merge()['groups']['errors']);
}
public function testTheJobDecidesWhatMayBeMerged(): void
{
$this->expectUserError('backup_job_not_running', fn() => $this->service()->merge(13, 'anna@firma.de', self::backup()));
$this->expectUserError('backup_job_not_running', fn() => $this->service()->merge('x', 'anna@firma.de', self::backup()));
$this->job(self::ALL, 'admin', [], 'done');
$this->expectUserError('backup_job_not_running', fn() => $this->merge());
$this->job(self::ALL);
$this->expectUserError('access_denied', fn() => $this->merge([], 'bob@firma.de'));
$this->expectUserError('backup_export_invalid', fn() => $this->merge(['mailbox' => ['username' => 'bob@firma.de']]));
$this->expectUserError('backup_export_invalid', fn() => $this->service()->merge(12, 'Anna@firma.de', 'text'));
$this->repo->tables['mailbox'] = array_values(array_filter($this->repo->tables['mailbox'], fn(array $m): bool => $m['username'] !== 'anna@firma.de'));
$this->expectUserError(['backup_mailbox_not_found', 'anna@firma.de'], fn() => $this->merge());
$this->assertSame([], $this->groupUpdates);
}
}
@@ -0,0 +1,341 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Service;
use Mailcow\Backup\Model\Settings;
use Mailcow\Backup\Repository\SettingsRepository;
use Mailcow\Backup\Service\SettingsService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SettingsServiceTest extends TestCase
{
private SettingsRepository&MockObject $repo;
/** @var array<string, string> what the repository holds */
private array $stored = [];
/** @var array[] values passed to save() */
private array $saved = [];
/** Key pair of the runner (sealed boxes) */
private string $keypair;
protected function setUp(): void
{
$this->keypair = sodium_crypto_box_keypair();
$this->stored = ['state_secrets_pubkey' => base64_encode(sodium_crypto_box_publickey($this->keypair))];
$this->repo = $this->createMock(SettingsRepository::class);
$this->repo->method('load')->willReturnCallback(fn(): array => $this->stored);
$this->repo->method('save')->willReturnCallback(function (array $values): void {
$this->saved[] = $values;
$this->stored = array_merge($this->stored, $values);
});
}
private function service(?Context $context = null): SettingsService
{
return new SettingsService($this->repo, $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());
}
}
/** A configured S3 target as the runner left it */
private function s3(array $values = []): void
{
$this->stored = $values + ['target' => 's3', 's3_bucket' => 'backups', 's3_access_key' => 'AKIA1', 's3_secret_key' => 'SECRET',
'password' => 'repo-password', 'password_confirmed' => '1', 'state_initialized' => '1', 'sftp_host_key' => 'ssh-ed25519 AAAA',
'state_secrets_pubkey' => base64_encode(sodium_crypto_box_publickey($this->keypair))];
}
/** What the runner decrypts from a stored secret */
private function open(string $stored): string|false
{
$this->assertStringStartsWith('sealed:v1:', $stored);
return sodium_crypto_box_seal_open((string)base64_decode(substr($stored, strlen('sealed:v1:')), true), $this->keypair);
}
public function testSecretsNeverLeaveTheApi(): void
{
$this->s3(['state_repo_stats' => '{"total_size": 5}', 'state_runner_seen' => '1700000000', 'unknown' => 'x']);
$settings = $this->service()->get()->toArray();
$this->assertSame([1, 1, 0], [$settings['password_set'], $settings['s3_secret_key_set'], $settings['rest_password_set']]);
$this->assertArrayNotHasKey('password', $settings);
$this->assertArrayNotHasKey('s3_secret_key', $settings);
$this->assertArrayNotHasKey('unknown', $settings);
$this->assertSame([7, 1700000000, null, ['total_size' => 5], 1], [$settings['keep_daily'], $settings['state_runner_seen'],
$settings['state_repo_size'], $settings['state_repo_stats'], $settings['configured']]);
$this->assertStringNotContainsString('SECRET', json_encode($settings));
}
public function testValuesAreNormalized(): void
{
$settings = $this->service()->save([
'target' => 'sftp', 'sftp_host' => 'u123.your-storagebox.de', 'sftp_port' => '23', 'sftp_user' => 'u123-sub1', 'sftp_path' => 'backups/mailcow/',
's3_path' => '/mailcow/', 'schedule' => [['days' => [5, '1', 3, 1], 'time' => '3:05'], ['days' => [0], 'time' => '14:00'], ['days' => [1, 3, 5], 'time' => '03:05']], 'max_unused' => '5', 'notify_email' => "a@example.org; b@example.org\nA@example.org",
'enabled' => ['0', '1'], 'include_fts' => false, 'keep_yearly' => 3, 'password' => 'long enough',
]);
$this->assertSame(['sftp', 'backups/mailcow', 'mailcow', '[{"days":[1,3,5],"time":"03:05"},{"days":[0],"time":"14:00"}]', '5%',
'a@example.org,b@example.org,A@example.org', '1', '0', '3'],
array_map(fn(string $name): string => $settings->get($name),
['target', 'sftp_path', 's3_path', 'schedule', 'max_unused', 'notify_email', 'enabled', 'include_fts', 'keep_yearly']));
$this->assertSame([[0, 14, 0], [1, 3, 5], [3, 3, 5], [5, 3, 5]], $settings->scheduleSlots());
$this->assertSame([['days' => [1, 3, 5], 'time' => '03:05'], ['days' => [0], 'time' => '14:00']], $settings->toArray()['schedule']);
// as JSON as well
$this->assertSame('[{"days":[2],"time":"23:59"}]', $this->service()->save(['schedule' => '[{"days":[2],"time":"23:59"}]'])->get('schedule'));
// secrets are stored sealed for the runner
$this->assertSame('long enough', $this->open($this->stored['password']));
// secrets as typed, spaces at the ends included
$this->service()->save(['password' => ' spaced password ', 's3_secret_key' => ' key ']);
$this->assertSame([' spaced password ', ' key '], [$this->open($this->stored['password']), $this->open($this->stored['s3_secret_key'])]);
// only changed values are written
$this->assertArrayNotHasKey('keep_daily', $this->saved[0]);
}
public static function invalidInput(): array
{
return [
'target' => [['target' => 'ftp'], 'target'],
'endpoint without scheme' => [['s3_endpoint' => 's3.example.org'], 's3_endpoint'],
'endpoint with credentials' => [['s3_endpoint' => 'https://key:secret@s3.example.org'], 's3_endpoint'],
'endpoint ftp' => [['s3_endpoint' => 'ftp://s3.example.org'], 's3_endpoint'],
'endpoint with a path' => [['s3_endpoint' => 'https://s3.example.org/bucket'], 's3_endpoint'],
'rest url with credentials' => [['rest_url' => 'https://user:pw@backup.example.org:8000/mailcow'], 'rest_url'],
'rest url with query' => [['rest_url' => 'https://backup.example.org/?x=1'], 'rest_url'],
'bucket' => [['s3_bucket' => 'my bucket'], 's3_bucket'],
'bucket too long' => [['s3_bucket' => str_repeat('b', 64)], 's3_bucket'],
's3 path up' => [['s3_path' => 'a/../b'], 's3_path'],
'region' => [['s3_region' => 'eu central'], 's3_region'],
'access key with space' => [['s3_access_key' => 'AK IA'], 's3_access_key'],
'sftp host option' => [['sftp_host' => '-oProxyCommand=x'], 'sftp_host'],
'sftp host with user' => [['sftp_host' => 'u1@host.example.org'], 'sftp_host'],
'sftp port' => [['sftp_port' => '70000'], 'sftp_port'],
'sftp user option' => [['sftp_user' => '-l'], 'sftp_user'],
// the runner's character set
'sftp user with @' => [['sftp_user' => 'u1@host'], 'sftp_user'],
'sftp user with +' => [['sftp_user' => 'u1+x'], 'sftp_user'],
'local path with ~' => [['local_path' => 'mailcow/~old'], 'local_path'],
'local path with space' => [['local_path' => 'mail cow'], 'local_path'],
'sftp path up' => [['sftp_path' => '/srv/../etc'], 'sftp_path'],
'sftp path root' => [['sftp_path' => '/'], 'sftp_path'],
'rest user with colon' => [['rest_user' => 'a:b'], 'rest_user'],
'local path absolute' => [['local_path' => '/etc'], 'local_path'],
'local path up' => [['local_path' => '../mailcow'], 'local_path'],
'local path empty' => [['local_path' => ''], 'local_path'],
'schedule time' => [['schedule' => [['days' => [1], 'time' => '24:00']]], 'schedule'],
'schedule without time' => [['schedule' => [['days' => [1]]]], 'schedule'],
'schedule day' => [['schedule' => [['days' => [1, 7], 'time' => '02:30']]], 'schedule'],
'schedule without day' => [['schedule' => [['days' => [], 'time' => '02:30']]], 'schedule'],
'schedule days as text' => [['schedule' => [['days' => '1,2', 'time' => '02:30']]], 'schedule'],
'schedule nested day' => [['schedule' => [['days' => [[1]], 'time' => '02:30']]], 'schedule'],
'schedule not a list' => [['schedule' => ['a' => ['days' => [1], 'time' => '02:30']]], 'schedule'],
'schedule broken json' => [['schedule' => '[{"days":'], 'schedule'],
'too many schedule entries' => [['schedule' => array_map(fn(int $h): array => ['days' => [1], 'time' => sprintf('%02d:00', $h % 24) . ''], range(0, 24))], 'schedule'],
'keep' => [['keep_daily' => '1001'], 'keep_daily'],
'keep hourly' => [['keep_hourly' => '1001'], 'keep_hourly'],
'keep negative' => [['keep_weekly' => '-1'], 'keep_weekly'],
'weekday' => [['prune_weekday' => '7'], 'prune_weekday'],
'max unused' => [['max_unused' => '101%'], 'max_unused'],
'max unused size' => [['max_unused' => '5G'], 'max_unused'],
'check subset' => [['check_subset' => '366'], 'check_subset'],
'limit upload' => [['limit_upload' => '-5'], 'limit_upload'],
'notify' => [['notify_email' => 'a@example.org, nobody'], 'notify_email'],
'flag' => [['enabled' => 'maybe'], 'enabled'],
'list as text' => [['sftp_host' => ['a', 'b']], 'sftp_host'],
'control character' => [['s3_secret_key' => "abc\ndef"], 's3_secret_key'],
];
}
#[DataProvider('invalidInput')]
public function testInvalidInput(array $input, string $name): void
{
$this->expectUserError(['backup_setting_invalid', $name], fn() => $this->service()->save($input));
$this->assertSame([], $this->saved);
}
public function testPruneCanBeOff(): void
{
$settings = $this->service()->save(['prune_weekday' => '']);
$this->assertSame(['', ''], [$settings->get('prune_weekday'), $settings->toArray()['prune_weekday']]);
$this->assertSame(3, $this->service()->save(['prune_weekday' => '3'])->toArray()['prune_weekday']);
$this->assertSame(0, (new Settings())->toArray()['prune_weekday']);
}
public function testSecretsNeedTheKeyOfTheRunner(): void
{
$this->stored = [];
$this->expectUserError('backup_secrets_key_missing', fn() => $this->service()->save(['keep_daily' => '9', 'password' => 'long enough']));
$this->assertSame([], $this->saved);
// everything else can be saved before the backup container ran
$this->assertSame('9', $this->service()->save(['keep_daily' => '9', 'password' => ''])->get('keep_daily'));
$this->stored['state_secrets_pubkey'] = base64_encode('too short');
$this->expectUserError('backup_secrets_key_missing', fn() => $this->service()->save(['rest_password' => 'x']));
}
public function testEverySecretIsSealedAnew(): void
{
$this->s3();
$this->service()->save(['s3_secret_key' => 'NEW-SECRET', 'rest_password' => 'rest pw']);
$this->assertSame(['NEW-SECRET', 'rest pw'], [$this->open($this->stored['s3_secret_key']), $this->open($this->stored['rest_password'])]);
$this->assertStringNotContainsString('NEW-SECRET', json_encode($this->saved));
$this->assertSame([1, 1], [$this->service()->get()->toArray()['s3_secret_key_set'], $this->service()->get()->toArray()['rest_password_set']]);
}
public function testATargetChangeIsReportedToTheAddressesOfBefore(): void
{
$this->s3(['notify_email' => 'old@example.org,boss@example.org', 's3_endpoint' => 'https://s3.example.org']);
$this->service()->save(['target' => 'local', 'local_path' => 'elsewhere', 'notify_email' => 'new@example.org']);
$change = json_decode($this->stored['state_target_changed'], true);
$this->assertSame(['target' => 's3', 's3_endpoint' => 'https://s3.example.org', 's3_bucket' => 'backups', 's3_path' => '', 's3_region' => '',
's3_access_key' => 'AKIA1', 's3_path_style' => '0'], $change['old_target']);
$this->assertSame([['old@example.org', 'boss@example.org'], ['target', 'notify_email'], 'admin'],
[$change['old_addresses'], $change['changed'], $change['changed_by']]);
$this->assertIsInt($change['time']);
$this->assertSame($change, $this->service()->get()->toArray()['state_target_changed']);
// a second change before the runner mailed the first keeps the older state
$this->service()->save(['local_path' => 'again']);
$this->assertSame($change, json_decode($this->stored['state_target_changed'], true));
}
public function testNewAddressesAreReportedToTheOldOnes(): void
{
$this->s3(['notify_email' => 'old@example.org']);
// new addresses first, then a new target: the original addresses hear about both
$this->service()->save(['notify_email' => 'attacker@example.net']);
$first = json_decode($this->stored['state_target_changed'], true);
$this->service()->save(['target' => 'local']);
$change = json_decode($this->stored['state_target_changed'], true);
$this->assertSame([['old@example.org'], ['notify_email']], [$first['old_addresses'], $first['changed']]);
$this->assertSame([['old@example.org'], ['notify_email', 'target'], 's3', $first['time']],
[$change['old_addresses'], $change['changed'], $change['old_target']['target'], $change['time']]);
}
public function testWhatIsNoTargetChange(): void
{
// other settings, a value of another target
$this->s3(['notify_email' => 'old@example.org']);
$this->service()->save(['keep_daily' => '3', 'rest_url' => 'https://r.example.org', 'notify_email' => 'old@example.org']);
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
// a new secret of the target is a change
$this->service()->save(['s3_secret_key' => 'NEW']);
$this->assertSame([['old@example.org'], ['target']], array_values(array_intersect_key(json_decode($this->stored['state_target_changed'], true),
['old_addresses' => 1, 'changed' => 1])));
// the first addresses of a backup
$this->s3();
$this->service()->save(['notify_email' => 'first@example.org']);
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
// a target that was not configured, or nobody to tell
$this->stored = ['state_secrets_pubkey' => $this->stored['state_secrets_pubkey'], 'notify_email' => 'old@example.org'];
$this->service()->save(['target' => 'local']);
$this->s3();
$this->service()->save(['target' => 'local']);
$this->assertArrayNotHasKey('state_target_changed', $this->stored);
// the runner's key cannot be set through the API
$this->service()->save(['state_target_changed' => '', 'state_secrets_pubkey' => 'x']);
$this->assertNotSame('x', $this->stored['state_secrets_pubkey']);
}
public function testShortRepositoryPassword(): void
{
$this->expectUserError(['backup_password_too_short', 8], fn() => $this->service()->save(['password' => 'short']));
}
public function testEmptySecretsAndRunnerValuesKeepTheStoredOnes(): void
{
$this->s3();
$settings = $this->service()->save(['password' => '', 's3_secret_key' => '', 'state_initialized' => '0', 'sftp_public_key' => 'evil',
'password_set' => 0, 'keep_daily' => '10']);
$this->assertSame(['repo-password', 'SECRET', '1', 'ssh-ed25519 AAAA', '', '10'], array_map(fn(string $name): string => $settings->get($name),
['password', 's3_secret_key', 'state_initialized', 'sftp_host_key', 'sftp_public_key', 'keep_daily']));
$this->assertSame([['keep_daily' => '10']], $this->saved);
}
public function testANewConnectionMustBeTestedAgain(): void
{
$this->s3();
// values of another target do not touch the connection
$this->assertSame('1', $this->service()->save(['rest_url' => 'https://backup.example.org:8000/mailcow'])->get('state_initialized'));
$this->assertSame('0', $this->service()->save(['s3_secret_key' => 'NEW'])->get('state_initialized'));
$this->s3();
$this->assertSame('0', $this->service()->save(['s3_path_style' => '1'])->get('state_initialized'));
$this->s3();
$this->assertSame('0', $this->service()->save(['target' => 'local'])->get('state_initialized'));
}
public function testANewSftpHostDropsTheKnownHostKey(): void
{
$this->s3(['target' => 'sftp', 'sftp_host' => 'old.example.org', 'sftp_user' => 'u1', 'sftp_path' => 'repo']);
$this->assertSame('ssh-ed25519 AAAA', $this->service()->save(['sftp_path' => 'repo2'])->get('sftp_host_key'));
$this->assertSame('', $this->service()->save(['sftp_port' => '23'])->get('sftp_host_key'));
$this->s3(['target' => 'sftp', 'sftp_host' => 'old.example.org', 'sftp_user' => 'u1', 'sftp_path' => 'repo']);
$this->assertSame('', $this->service()->save(['sftp_host' => '192.0.2.10'])->get('sftp_host_key'));
}
public function testANewRepositoryPasswordNeedsANewConfirmation(): void
{
$this->s3();
$settings = $this->service()->save(['password' => 'another password']);
$this->assertSame(['0', '0'], [$settings->get('password_confirmed'), $settings->get('state_initialized')]);
$this->s3();
$this->assertSame('1', $this->service()->save(['password' => 'another password', 'password_confirmed' => '1'])->get('password_confirmed'));
}
public function testTheScheduleNeedsAConfiguredTarget(): void
{
$this->expectUserError('backup_not_configured', fn() => $this->service()->save(['enabled' => '1']));
$this->s3();
$this->assertSame('1', $this->service()->save(['enabled' => '1'])->get('enabled'));
$this->expectUserError('backup_not_configured', fn() => $this->service()->save(['s3_bucket' => '']));
// and at least one time
$this->expectUserError(['backup_setting_invalid', 'schedule'], fn() => $this->service()->save(['schedule' => []]));
$this->assertSame('[]', $this->service()->save(['enabled' => '0', 'schedule' => []])->get('schedule'));
}
public function testOnlyAdminsReadAndOnlyWritingAdminsSave(): void
{
$this->s3();
$readOnly = new Context('admin', 'API', [], false, 'ro');
$this->assertNotNull($this->service($readOnly)->get());
$this->assertNull($this->service(FakeContext::domainAdmin([], ['firma.de']))->get());
$this->assertNull($this->service(FakeContext::user('anna@firma.de'))->get());
foreach ([$readOnly, FakeContext::domainAdmin([], ['firma.de']), FakeContext::user('anna@firma.de'), Context::system()] as $context) {
$this->expectUserError('access_denied', fn() => $this->service($context)->save(['keep_daily' => '1']));
}
$this->assertSame([], $this->saved);
}
public function testConfiguredPerTarget(): void
{
$settings = Settings::fromStored(['password' => 'x', 'target' => 'local']);
$this->assertTrue($settings->configured());
$this->assertFalse(Settings::fromStored(['target' => 'local'])->configured());
$this->assertFalse(Settings::fromStored(['password' => 'x', 'target' => 'rest'])->configured());
$this->assertTrue(Settings::fromStored(['password' => 'x', 'target' => 'rest', 'rest_url' => 'https://r.example.org'])->configured());
$this->assertFalse(Settings::fromStored(['password' => 'x', 'target' => 'sftp', 'sftp_host' => 'h', 'sftp_user' => 'u'])->configured());
}
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Backup\Support;
use Mailcow\Backup\Repository\RestoreRepository;
use PDOException;
use Throwable;
/**
* RestoreRepository on in-memory tables: the merge rules are tested against table contents instead of SQL. Strings
* compare case-insensitively like the MariaDB collation; transactions and savepoints restore a copy of the tables.
*/
class FakeRestoreRepository extends RestoreRepository
{
/** @var array<string, array[]> table => rows */
public array $tables = [];
/** Inserts into these tables fail with a database error */
public array $failingTables = [];
/** Named locks that are taken by another request */
public array $busyLocks = [];
private const SCHEMA = [
'domain' => ['domain', 'description', 'aliases', 'mailboxes', 'defquota', 'maxquota', 'quota', 'relayhost', 'backupmx', 'gal', 'created', 'modified', 'active'],
'tags_domain' => ['tag_name', 'domain'],
'domain_wide_footer' => ['domain', 'html', 'plain', 'mbox_exclude', 'alias_domain_exclude', 'skip_replies'],
'mta_sts' => ['id', 'domain', 'version', 'mode', 'mx', 'max_age', 'active'],
'domain_admins' => ['id', 'username', 'domain', 'created', 'active'],
'admin' => ['username', 'superadmin', 'active'],
'da_acl' => ['username', 'syncjobs', 'quarantine', 'app_passwds', 'pushover', 'spam_policy', 'filters', 'mailbox_groups', 'mail_policies',
'retention', 'protocol_access', 'mailbox_relayhost', 'unlimited_quota', 'ratelimit', 'extend_sender_acl', 'alias_external_goto'],
'quota2' => ['username', 'bytes', 'messages'],
'alias_domain' => ['alias_domain', 'target_domain', 'active'],
'mailbox' => ['username', 'password', 'name', 'quota', 'local_part', 'domain', 'attributes', 'custom_attributes', 'kind', 'authsource', 'created', 'modified', 'active'],
'alias' => ['id', 'address', 'goto', 'domain', 'created', 'modified', 'private_comment', 'public_comment', 'sogo_visible', 'internal',
'sender_allowed', 'active', 'group_id'],
'sender_acl' => ['id', 'logged_in_as', 'send_as', 'external', 'group_id'],
'spamalias' => ['address', 'goto', 'description', 'created', 'modified', 'validity', 'permanent'],
'filterconf' => ['prefid', 'object', 'option', 'value', 'created', 'modified'],
'sieve_filters' => ['id', 'username', 'script_desc', 'script_name', 'script_data', 'filter_type', 'created', 'modified'],
'bcc_maps' => ['id', 'local_dest', 'bcc_dest', 'domain', 'type', 'created', 'modified', 'active'],
'app_passwd' => ['id', 'name', 'mailbox', 'domain', 'password', 'created', 'modified', 'imap_access', 'smtp_access', 'active'],
'tfa' => ['id', 'key_id', 'username', 'authmech', 'secret', 'keyHandle', 'publicKey', 'counter', 'certificate', 'active'],
'fido2' => ['username', 'friendlyName', 'rpId', 'credentialPublicKey', 'AAGUID', 'credentialId', 'created', 'modified', 'active'],
'tags_mailbox' => ['tag_name', 'username'],
'user_acl' => ['username', 'spam_alias', 'syncjobs', 'pushover', 'quarantine', 'app_passwds'],
'pushover' => ['username', 'key', 'token', 'attributes', 'title', 'active'],
'imapsync' => ['id', 'user2', 'source_id', 'user1', 'password1', 'maxage', 'created', 'modified', 'is_running', 'returned_text',
'last_run', 'success', 'exit_status', 'prio', 'active'],
'imapsync_source' => ['id', 'host1', 'port1', 'scope'],
'imapsync_source_domain' => ['source_id', 'domain'],
'imapsync_source_user' => ['source_id', 'username'],
'mailbox_group' => ['id', 'domain', 'name', 'address'],
'mailbox_group_member' => ['group_id', 'username', 'source'],
'mail_policy' => ['id', 'domain', 'name', 'send_mode', 'send_allow', 'send_block', 'receive_mode', 'receive_allow', 'receive_block', 'created'],
'mail_policy_member' => ['id', 'policy_id', 'username', 'group_id'],
'retention_rule' => ['id', 'domain', 'name', 'folders', 'days', 'created'],
'retention_rule_member' => ['id', 'rule_id', 'username', 'group_id'],
'relayhosts' => ['id', 'hostname'],
];
public function __construct(array $tables = [])
{
$this->tables = array_fill_keys(array_keys(self::SCHEMA), []);
foreach ($tables as $table => $rows) {
foreach ($rows as $row) {
$this->tables[$table][] = $row;
}
}
}
public function inTransaction(callable $work): mixed
{
return $this->savepoint('transaction', $work);
}
public function savepoint(string $name, callable $work): mixed
{
$copy = $this->tables;
try {
return $work();
} catch (Throwable $e) {
$this->tables = $copy;
throw $e;
}
}
public function locked(string $key, callable $work, int $timeout = 120): mixed
{
return in_array($key, $this->busyLocks, true) ? null : $work();
}
public function resetQuota(string $username): void
{
$this->tables['quota2'] = array_values(array_filter($this->tables['quota2'], fn(array $row): bool => $row['username'] !== $username));
$this->tables['quota2'][] = ['username' => $username, 'bytes' => 0, 'messages' => 0];
}
public function mailboxCount(string $domain): int
{
return count($this->rows('mailbox', ['domain' => $domain, 'kind' => '']));
}
public function dropExternalSenderAcl(string $domain): void
{
$this->tables['sender_acl'] = array_values(array_filter($this->tables['sender_acl'], fn(array $row): bool =>
!((int)$row['external'] === 1 && str_ends_with(strtolower((string)$row['send_as']), '@' . strtolower($domain)))));
}
public function columns(string $table): array
{
return self::SCHEMA[$table];
}
public function rows(string $table, array $where): array
{
return array_values(array_filter($this->tables[$table], fn(array $row): bool => self::matches($row, $where)));
}
public function insert(string $table, array $values): int
{
if (in_array($table, $this->failingTables, true)) {
throw new PDOException('SQLSTATE[23000]: duplicate', 23000);
}
$row = array_fill_keys(self::SCHEMA[$table], null);
foreach (array_intersect_key($values, $row) as $column => $value) {
$row[$column] = $value;
}
$id = 0;
foreach (['id', 'prefid'] as $key) {
if (array_key_exists($key, $row)) {
$id = $row[$key] = 1 + max([0, ...array_map(fn(array $r): int => (int)$r[$key], $this->tables[$table])]);
}
}
$this->tables[$table][] = $row;
return $id;
}
public function update(string $table, array $where, array $values): int
{
$count = 0;
foreach ($this->tables[$table] as &$row) {
if (self::matches($row, $where)) {
$row = array_merge($row, array_intersect_key($values, array_flip(self::SCHEMA[$table])));
$count++;
}
}
return $count;
}
public function domainExists(string $domain): bool
{
return $this->primaryDomain($domain) !== null;
}
public function primaryDomain(string $domain): ?string
{
if (!empty($this->rows('domain', ['domain' => $domain]))) {
return strtolower($domain);
}
$alias = $this->rows('alias_domain', ['alias_domain' => $domain])[0] ?? null;
return ($alias === null) ? null : (string)$alias['target_domain'];
}
public function isLocalDomain(string $domain): bool
{
return !empty($this->rows('domain', ['domain' => $domain, 'active' => 1])) || !empty($this->rows('alias_domain', ['alias_domain' => $domain, 'active' => 1]));
}
public function aliasDomainsOf(string $domain): array
{
return array_map(fn(array $row): string => (string)$row['alias_domain'], $this->rows('alias_domain', ['target_domain' => $domain]));
}
public function addressKind(string $address): ?string
{
$mailbox = $this->rows('mailbox', ['username' => $address])[0] ?? null;
if ($mailbox !== null) {
return preg_match('/location|thing|group/', (string)$mailbox['kind']) ? 'resource' : 'mailbox';
}
[$local, $domain] = array_pad(explode('@', $address, 2), 2, '');
foreach ($this->rows('alias_domain', ['alias_domain' => $domain]) as $aliasDomain) {
if (!empty($this->rows('mailbox', ['username' => $local . '@' . $aliasDomain['target_domain']]))) {
return 'mailbox';
}
}
if (!empty($this->rows('alias', ['address' => $address]))) {
return 'alias';
}
return empty($this->rows('spamalias', ['address' => $address])) ? null : 'spamalias';
}
public function aliasesLeft(string $domain): int
{
$domains = array_merge([$domain], $this->aliasDomainsOf($domain));
$used = array_filter($this->tables['alias'], fn(array $alias): bool => in_array(strtolower((string)$alias['domain']), $domains, true)
&& empty($this->rows('mailbox', ['username' => $alias['address']])));
return (int)($this->rows('domain', ['domain' => $domain])[0]['aliases'] ?? 0) - count($used);
}
public function quotaInUse(string $domain, string $exceptUsername): int
{
return array_sum(array_map(fn(array $m): int => (int)$m['quota'], array_filter($this->rows('mailbox', ['domain' => $domain, 'kind' => '']),
fn(array $m): bool => strtolower((string)$m['username']) !== strtolower($exceptUsername))));
}
public function syncJobsOf(string $username): array
{
return array_map(function (array $job): array {
$source = $this->rows('imapsync_source', ['id' => $job['source_id']])[0] ?? [];
return ['user1' => $job['user1'], 'host1' => $source['host1'] ?? null, 'port1' => $source['port1'] ?? null];
}, $this->rows('imapsync', ['user2' => $username]));
}
public function syncSourcesFor(string $host, int $port, string $username, string $domain): array
{
$ids = [];
foreach ($this->rows('imapsync_source', ['host1' => $host, 'port1' => $port]) as $source) {
if ($source['scope'] === 'all'
|| ($source['scope'] === 'domain' && !empty($this->rows('imapsync_source_domain', ['source_id' => $source['id'], 'domain' => $domain])))
|| ($source['scope'] === 'user' && !empty($this->rows('imapsync_source_user', ['source_id' => $source['id'], 'username' => $username])))) {
$ids[] = (int)$source['id'];
}
}
sort($ids);
return $ids;
}
private static function matches(array $row, array $where): bool
{
foreach ($where as $column => $value) {
$actual = $row[$column] ?? null;
if ($value === null ? $actual !== null : ($actual === null || strtolower((string)$actual) !== strtolower((string)$value))) {
return false;
}
}
return true;
}
}