repo = $this->createMock(RuleRepository::class); $this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true)); $this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null); $this->repo->method('groupDomain')->willReturnCallback(fn(int $id): ?string => [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null); $this->repo->method('groupsIn')->willReturn([['id' => 7, 'name' => 'Azubis', 'domain' => 'firma.de', 'address' => null]]); $this->repo->method('save')->willReturnCallback(function (Rule $rule): int { $rule->id ??= 1; $this->saved[] = clone $rule; return $rule->id; }); } private function service(?Context $context = null): RuleService { return new RuleService($this->repo, $context ?? FakeContext::admin()); } private function stored(array $values = []): Rule { $rule = new Rule(); $rule->id = 3; $rule->domain = 'firma.de'; $rule->name = 'Papierkorb'; $rule->folders = ['trash', 'folder:Newsletter']; $rule->groups = [7]; foreach ($values as $property => $value) { $rule->$property = $value; } return $rule; } 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 testCreateNormalizesTheInput(): void { $rule = $this->service()->create([ 'domain' => 'Firma.de', 'name' => 'Aufräumen', 'folders' => ['', 'trash', 'junk', 'trash'], 'own_folders' => "Projekte/*\n\n /Newsletter/ ", 'days' => '30', 'keep_flagged' => ['0', '1'], 'all_mailboxes' => ['0'], 'mailboxes' => ['', 'Azubi@firma.de'], 'groups' => ['7'], ]); $this->assertSame(['domain' => 'firma.de', 'name' => 'Aufräumen', 'folders' => ['trash', 'junk', 'folder:Projekte/*', 'folder:Newsletter'], 'days' => 30, 'keep_flagged' => 1, 'mode' => 'simulate', 'all_mailboxes' => 0, 'active' => 1, 'mailboxes' => ['azubi@firma.de'], 'groups' => [7]], array_intersect_key($rule->toArray(), array_flip(['domain', 'name', 'folders', 'days', 'keep_flagged', 'mode', 'all_mailboxes', 'active', 'mailboxes', 'groups']))); } public function testUpdateKeepsMissingValuesAndReplacesOwnFolders(): void { $this->repo->method('find')->willReturn($this->stored()); $this->assertSame(['trash', 'folder:Archiv/2020'], $this->service()->update(3, ['own_folders' => 'Archiv/2020'])->folders); // the API names all folders in folders, the UI sends the own folders apart $this->assertSame(['junk', 'folder:Alt'], $this->service()->update(3, ['folders' => ['junk', 'folder:Alt']])->folders); $this->assertSame(['sent', 'folder:Neu'], $this->service()->update(3, ['folders' => ['sent'], 'own_folders' => 'Neu'])->folders); // a list keeps commas in names, INBOX is written like Dovecot does $this->assertSame(['sent', 'folder:Kunden, alt', 'folder:INBOX/Alt', 'folder:Inboxen'], $this->service()->update(3, ['own_folders' => ['Kunden, alt', 'inbox/Alt', 'Inboxen']])->folders); $this->assertSame('delete', $this->service()->update(3, ['mode' => 'delete'])->mode); } public static function invalidInput(): array { return [ 'no folder' => [['folders' => '', 'own_folders' => ''], 'retention_folders_invalid'], 'unknown folder' => [['folders' => ['spam']], ['retention_folder_invalid', 'spam']], 'folder with control character' => [['own_folders' => "a\tb"], ['retention_folder_invalid', "folder:a\tb"]], 'folder list with a list' => [['own_folders' => [['a']]], ['retention_folder_invalid', '']], 'no days' => [['days' => '0'], ['retention_days_invalid', 36500]], 'too many days' => [['days' => '36501'], ['retention_days_invalid', 36500]], 'mode' => [['mode' => 'now'], 'retention_mode_invalid'], 'name' => [['name' => ''], 'retention_name_invalid'], 'mailbox of another domain' => [['mailboxes' => 'andere@tochter.de'], ['retention_member_invalid', 'andere@tochter.de']], 'group of another domain' => [['groups' => ['9']], ['retention_member_invalid', '9']], 'too many mailboxes' => [['mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))], ['retention_entries_exceeded', 500]], ]; } #[DataProvider('invalidInput')] public function testInvalidInput(array $input, string|array $error): void { $this->expectUserError($error, fn() => $this->service()->create($input + ['domain' => 'firma.de', 'name' => 'Test', 'folders' => ['trash'], 'days' => 30])); $this->assertSame([], $this->saved); } public function testPermissions(): void { $this->repo->method('find')->willReturn($this->stored()); $da = FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1', 'mailbox_groups' => '1']); $create = fn(string $domain): array => ['domain' => $domain, 'name' => 'Test', 'folders' => ['trash'], 'days' => 30]; $this->assertSame('Test', $this->service($da)->create($create('firma.de'))->name); $this->assertNotNull($this->service($da)->get(3)); $this->expectUserError('access_denied', fn() => $this->service($da)->create($create('tochter.de'))); $this->assertNull($this->service(FakeContext::domainAdmin([], ['tochter.de'], ['retention' => '1']))->get(3)); $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']))->create($create('firma.de'))); $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('azubi@firma.de'))->update(3, [])); $readOnly = new Context('admin', 'API', [], false, 'ro'); $this->assertNotNull($this->service($readOnly)->get(3)); $this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3)); } public function testGroupsNeedTheGroupAcl(): void { $this->repo->method('find')->willReturn($this->stored()); $da = FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1']); $this->assertSame([], $this->service($da)->groupChoices(['firma.de'])); // the form sends no group field: the assignment stays; sending it back unchanged is accepted $this->assertSame([7], $this->service($da)->update(3, ['days' => 60])->groups); $this->assertSame([7], $this->service($da)->update(3, ['groups' => ['7']])->groups); $this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']])); $this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['retention' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de'])); } public function testEffectiveRulesOfAMailbox(): void { $rule = $this->stored(['folders' => ['trash'], 'days' => 30]); $this->repo->method('candidates')->willReturn(['azubi@firma.de' => [['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN]]]); $this->assertSame('trash', $this->service(FakeContext::user('Azubi@firma.de'))->effective('azubi@firma.de')[0]['folder']); // users only see their own mailbox $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('chef@firma.de'))->effective('azubi@firma.de')); $this->assertCount(2, $this->service()->effective('azubi@firma.de')); // domain admins need the ACL as well $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(['azubi@firma.de'], ['firma.de'], []))->effective('azubi@firma.de')); $this->assertCount(2, $this->service(FakeContext::domainAdmin(['azubi@firma.de'], ['firma.de'], ['retention' => '1']))->effective('azubi@firma.de')); } public function testThePlanComesInPages(): void { $rule = $this->stored(['folders' => ['all'], 'days' => 30]); $this->repo->method('mailboxesWithRules')->willReturnCallback(fn(string $after, int $limit): array => array_slice(array_values(array_filter(['a@firma.de', 'b@firma.de', 'c@firma.de'], fn(string $u): bool => $u > $after)), 0, $limit)); $this->repo->method('candidates')->willReturnCallback(fn(array $usernames): array => array_fill_keys(array_diff($usernames, ['b@firma.de']), [['rule' => $rule, 'level' => Planner::LEVEL_DOMAIN]])); $first = $this->service(Context::system())->plan('', 2); // b has no rule of its own: not in the plan, but the page goes on after it $this->assertSame(['a@firma.de'], array_keys($first['mailboxes'])); $this->assertSame('b@firma.de', $first['next']); $last = $this->service(Context::system())->plan('b@firma.de', 2); $this->assertSame(['c@firma.de'], array_keys($last['mailboxes'])); $this->assertNull($last['next']); } public function testReportDropsBrokenEntries(): void { $this->repo->expects($this->once())->method('addLog')->with( [['username' => 'azubi@firma.de', 'rule_id' => 3, 'folder' => 'Trash', 'matched' => 2]], [['rule_id' => 3, 'mailboxes' => 1]], ['mailboxes' => 1] )->willReturn(3); $this->assertSame(3, $this->service(Context::system())->report( [['username' => 'azubi@firma.de', 'rule_id' => 3, 'folder' => 'Trash', 'matched' => 2], ['username' => ['x']], 'text'], [['rule_id' => 3, 'mailboxes' => 1], ['rule_id' => 'x']], ['mailboxes' => 1] )); // the runner sends parts of 200 $this->expectUserError(['retention_entries_exceeded', 1000], fn() => $this->service(Context::system())->report(array_fill(0, 1001, []), [])); } }