[Web][Tests] Add mailbox group, identity sync, auth, Dovecot and SOGo module tests

This commit is contained in:
FreddleSpl0it committed 2026-10-27 10:15:00 +01:00
1 parent 2c5718c9c9
commit ad86403b87
19 files changed
+1997

No files matched your search

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Controller;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Controller\MailboxGroupApiController;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Service\AccountHooks;
use Mailcow\MailboxGroup\Service\GroupService;
use Mailcow\MailboxGroup\Service\TemplateService;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class MailboxGroupApiControllerTest extends TestCase
{
private GroupService&MockObject $groups;
private AccountHooks&MockObject $hooks;
private TemplateService&MockObject $templates;
private MailboxGroupApiController $api;
protected function setUp(): void
{
$_SESSION = [];
$this->groups = $this->createMock(GroupService::class);
$this->hooks = $this->createMock(AccountHooks::class);
$this->templates = $this->createMock(TemplateService::class);
$this->api = new MailboxGroupApiController($this->groups, $this->hooks, $this->templates, new ApiResponse());
}
private static function group(string $name, array $warnings = []): Group
{
$group = Group::fromRow(['id' => 7, 'domain' => 'example.org', 'name' => $name]);
$group->warnings = $warnings;
return $group;
}
/** Messages written to the session as [type, msg] */
private function messages(): array
{
return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []);
}
public function testAddReportsWarningsAndTheEscapedName(): void
{
$this->groups->method('create')->willReturn(self::group('<Sales>', [['group_template_failed', 'Staff', 'a@example.org']]));
$this->assertTrue($this->api->addGroup(['name' => '<Sales>']));
$this->assertSame([
['warning', ['group_template_failed', 'Staff', 'a@example.org']],
['success', ['group_added', '&lt;Sales&gt;']],
], $this->messages());
$this->assertSame(['mailbox_group', 'add', 'group', ['name' => '<Sales>']], $_SESSION['return'][1]['log']);
}
public function testErrorsBecomeDangerMessages(): void
{
$this->groups->method('create')->willReturnOnConsecutiveCalls(
$this->throwException(new UserError('group_name_invalid')),
$this->throwException(new PDOException('gone away'))
);
$this->assertFalse($this->api->addGroup([]));
$this->assertFalse($this->api->addGroup([]));
$this->assertSame([['danger', 'group_name_invalid'], ['danger', ['mysql_error', 'gone away']]], $this->messages());
}
public function testEditReportsEachId(): void
{
$this->groups->method('update')->willReturnCallback(fn(int $id, array $attr): Group => ($id === 2)
? throw new UserError('group_member_idp', ['a@example.org']) : self::group('G' . $id));
$this->assertTrue($this->api->editGroups(['1', '2', 'x'], ['active' => '0']));
$this->assertSame([
['success', ['group_modified', 'G1']],
['danger', ['group_member_idp', 'a@example.org']],
['danger', 'access_denied'],
], $this->messages());
}
public function testEditAndDeleteNeedAnEditorOnce(): void
{
$this->groups->method('requireEditor')->willThrowException(new AccessDenied());
$this->groups->expects($this->never())->method('update');
$this->groups->expects($this->never())->method('delete');
$this->assertFalse($this->api->editGroups(['1', '2'], []));
$this->assertFalse($this->api->deleteGroups(['1', '2']));
$this->assertSame([['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
}
public function testDelete(): void
{
$this->groups->expects($this->once())->method('delete')->with(7)->willReturn(self::group('Sales'));
$this->assertTrue($this->api->deleteGroups(['7']));
$this->assertSame([['success', ['group_removed', 'Sales']]], $this->messages());
}
public function testGetGroupsSplitsTheDomainList(): void
{
$this->groups->expects($this->once())->method('visibleIds')->with(['a.org', 'b.org'])->willReturn([7]);
$this->groups->method('get')->willReturnMap([[7, self::group('Sales')]]);
$this->groups->method('toArray')->willReturn(['id' => 7]);
$this->assertSame([['id' => 7]], $this->api->getGroups(' a.org,,b.org '));
$this->assertSame([], $this->api->getGroup('all'));
}
public function testHooksTurnDatabaseErrorsIntoMessages(): void
{
$this->hooks->method('onMailboxDeleted')->willThrowException(new PDOException('locked'));
$this->api->mailboxDeleted('a@example.org');
$this->assertSame([['danger', ['mysql_error', 'locked']]], $this->messages());
}
public function testTemplateChangesReportWarnings(): void
{
$this->templates->method('onTemplateChanged')->with(3)->willReturn([['group_template_failed', 'Staff', 'a@example.org']]);
$this->api->templateChanged(3);
$this->assertSame([['warning', ['group_template_failed', 'Staff', 'a@example.org']]], $this->messages());
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Legacy;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
use PHPUnit\Framework\TestCase;
use RuntimeException;
class LegacyMailboxTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = ['return' => [['type' => 'success', 'msg' => 'caller']], 'access_all_exception' => '0'];
}
/** Replaces the global calls: $result is returned, $messages are written like the legacy code does */
private static function legacy(mixed $result, array $messages = [], array &$calls = []): LegacyMailbox
{
return new class($result, $messages, $calls) extends LegacyMailbox {
private array $calls;
public function __construct(private mixed $result, private array $messages, array &$calls)
{
$this->calls = &$calls;
}
protected function callTemplate(string $username, string $name, string $template): mixed
{
$this->calls[] = [$username, $name, $template, $_SESSION['access_all_exception']];
if ($this->result instanceof RuntimeException) {
throw $this->result;
}
$_SESSION['return'] = array_merge($_SESSION['return'], $this->messages);
return $this->result;
}
};
}
public function testTemplatesAreAppliedAsSystemAndTheSessionIsRestored(): void
{
$calls = [];
$this->assertNull(self::legacy(true, [['type' => 'success', 'msg' => 'mailbox_modified']], $calls)->applyTemplate('a@example.org', 'A', 'Staff'));
$this->assertSame([['a@example.org', 'A', 'Staff', '1']], $calls);
$this->assertSame(['return' => [['type' => 'success', 'msg' => 'caller']], 'access_all_exception' => '0'], $_SESSION);
}
public function testTheFirstLegacyErrorIsReturned(): void
{
$error = self::legacy(false, [['type' => 'success', 'msg' => 'x'], ['type' => 'danger', 'msg' => ['mailbox_quota_exceeded', 1]]])
->applyTemplate('a@example.org', 'A', 'Staff');
$this->assertSame('mailbox_quota_exceeded', $error);
$this->assertSame('0', $_SESSION['access_all_exception']);
}
public function testTheSessionIsRestoredOnExceptions(): void
{
try {
self::legacy(new RuntimeException('boom'))->applyTemplate('a@example.org', 'A', 'Staff');
$this->fail('exception expected');
} catch (RuntimeException) {
}
$this->assertSame(['return' => [['type' => 'success', 'msg' => 'caller']], 'access_all_exception' => '0'], $_SESSION);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Service;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\TemplateService;
use Mailcow\Sogo\Service\SogoService;
use PHPUnit\Framework\TestCase;
class ChangeEffectsTest extends TestCase
{
public function testCachesAreFlushedForTheWritesOfTheRepository(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('takeEffects')->willReturn(['members' => ['a@example.org'], 'sogo_acl' => true]);
$legacy = $this->createMock(SogoService::class);
$legacy->expects($this->once())->method('refreshStaticView')->with(['b@example.org']);
$legacy->expects($this->once())->method('flushCache');
$dovecot = $this->createMock(DovecotService::class);
$dovecot->expects($this->once())->method('flushAuthCache')->with(['a@example.org']);
$templates = $this->createMock(TemplateService::class);
$templates->method('applyChanged')->willReturn([]);
(new ChangeEffects($templates, $legacy, $repo, $dovecot))->apply(['b@example.org'], []);
}
public function testSogoCacheStaysWithoutAclChanges(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('takeEffects')->willReturn(['members' => [], 'sogo_acl' => false]);
$legacy = $this->createMock(SogoService::class);
$legacy->expects($this->never())->method('flushCache');
$templates = $this->createMock(TemplateService::class);
$templates->method('applyChanged')->willReturn([]);
(new ChangeEffects($templates, $legacy, $repo, $this->createMock(DovecotService::class)))->apply([], []);
}
}
@@ -0,0 +1,359 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Service;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\GroupService;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\MailboxGroup\Service\TemplateService;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class GroupServiceTest extends TestCase
{
private const ACL = ['mailbox_groups' => '1'];
private GroupRepository&MockObject $repo;
private LookupRepository&MockObject $lookup;
private TemplateService&MockObject $templates;
private ChangeEffects&MockObject $effects;
private SharingService&MockObject $sharing;
protected function setUp(): void
{
$this->repo = $this->createMock(GroupRepository::class);
$this->lookup = $this->createMock(LookupRepository::class);
$this->templates = $this->createMock(TemplateService::class);
$this->effects = $this->createMock(ChangeEffects::class);
$this->effects->method('snapshot')->willReturn([]);
$this->effects->method('apply')->willReturn([]);
$this->sharing = $this->createMock(SharingService::class);
$this->sharing->method('applyImap')->willReturn([]);
$this->sharing->method('applySogo')->willReturn([]);
$this->lookup->method('isDomain')->willReturnCallback(fn(string $d): bool => in_array($d, ['example.org', 'other.org'], true));
$this->lookup->method('isDomainOrAliasDomain')->willReturnCallback(fn(string $d): bool => in_array($d, ['example.org', 'other.org', 'alias.org'], true));
$this->lookup->method('aliasExists')->willReturnCallback(fn(string $a): bool => str_starts_with($a, 'shared@') || str_starts_with($a, 'a@'));
$this->lookup->method('regularMailboxes')->willReturnCallback(fn(array $u): array => array_values(array_filter($u, fn(string $x): bool => !str_starts_with($x, 'room'))));
$this->lookup->method('aliasesLeft')->willReturn(10);
$this->lookup->method('templateName')->willReturnCallback(fn(int $id): ?string => $id === 3 ? 'Staff' : null);
}
private function service(Context $context): GroupService
{
return new GroupService($this->repo, $this->lookup, $this->templates, $this->effects, $this->sharing, $context);
}
private static function admin(): FakeContext
{
return FakeContext::admin(self::ACL);
}
/** Domain admin of example.org with its mailboxes a@ and b@ */
private static function domainAdmin(array $acl = self::ACL): FakeContext
{
return FakeContext::domainAdmin(['a@example.org', 'b@example.org'], ['example.org'], $acl);
}
private static function stored(array $row = [], array $members = ['a@example.org' => 'manual'], array $sendAs = []): Group
{
$group = Group::fromRow($row + ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales', 'address' => 'sales@example.org',
'sender_allowed' => 1, 'active' => 1, 'priority' => 0]);
$group->members = $members;
$group->sendAs = $sendAs;
return $group;
}
private function expectUserError(string|array $msg, callable $call): void
{
try {
$call();
$this->fail('UserError ' . json_encode($msg) . ' expected');
} catch (UserError $e) {
$this->assertSame($msg, $e->msg());
}
}
public static function deniedEditors(): array
{
return [
'user' => [FakeContext::user('a@example.org', self::ACL)],
'domain admin without ACL' => [self::domainAdmin([])],
'read-only API key' => [new Context('admin', 'API', self::ACL, false, 'ro')],
'system' => [Context::system()],
];
}
#[DataProvider('deniedEditors')]
public function testOnlyAdminsAndDomainAdminsWithAclMayWrite(Context $context): void
{
$this->repo->expects($this->never())->method('insert');
$this->expectUserError('access_denied', fn() => $this->service($context)->create(['domain' => 'example.org', 'name' => 'X']));
}
public function testReadOnlyApiKeyMayRead(): void
{
$this->repo->expects($this->once())->method('visibleIds')->willReturn([7]);
$this->assertSame([7], $this->service(new Context('admin', 'API', self::ACL, false, 'ro'))->visibleIds());
}
public function testCreateNormalisesInput(): void
{
$this->repo->expects($this->once())->method('insert')->with($this->callback(function (Group $g): bool {
$this->assertSame('example.org', $g->domain);
$this->assertSame('Sales', $g->name);
$this->assertSame('sales@example.org', $g->address);
$this->assertSame(['a@example.org' => 'manual', 'b@example.org' => 'manual'], $g->members);
$this->assertSame(['@alias.org', 'shared@example.org'], $g->sendAs);
$this->assertSame([1, 0, 1, 1], [$g->senderAllowed, $g->sogoVisible, $g->internal, $g->active]);
$this->assertSame([3, 'Staff', 5], [$g->templateId, $g->template, $g->priority]);
return true;
}))->willReturn([]);
$group = $this->service(self::admin())->create([
'domain' => ' Example.ORG ', 'name' => ' Sales ', 'local_part' => 'Sales',
'members' => "B@example.org\na@example.org, a@example.org", 'send_as' => ['Shared@Example.org', '@alias.org'],
'sender_allowed' => ['0', '1'], 'sogo_visible' => '0', 'internal' => 'true',
'template_id' => '3', 'priority' => '5',
]);
$this->assertSame([], $group->warnings);
}
public static function invalidInput(): array
{
return [
'empty name' => [['name' => ' '], 'group_name_invalid'],
'long name' => [['name' => str_repeat('x', 101)], 'group_name_invalid'],
'long description' => [['description' => str_repeat('x', 256)], 'group_description_invalid'],
'unknown domain' => [['domain' => 'nope.org'], ['domain_not_found', 'nope.org']],
'address in other domain' => [['address' => 'sales@other.org'], ['group_address_invalid', 'sales@other.org']],
'invalid address' => [['local_part' => 'a b'], ['group_address_invalid', 'a b@example.org']],
'member is a resource' => [['members' => ['room1@example.org']], ['group_member_invalid', 'room1@example.org']],
'unknown send-as address' => [['send_as' => 'nobody@example.org'], ['group_send_as_invalid', 'nobody@example.org']],
'send-as on own address' => [['local_part' => 'shared', 'send_as' => 'shared@example.org'], ['group_send_as_invalid', 'shared@example.org']],
'send-as on unknown domain' => [['send_as' => '@nope.org'], ['group_send_as_invalid', '@nope.org']],
'unknown template' => [['template_id' => '4'], ['template_id_invalid', '4']],
'invalid priority' => [['priority' => 'high'], 'group_priority_invalid'],
];
}
#[DataProvider('invalidInput')]
public function testCreateRejectsInvalidInput(array $input, string|array $msg): void
{
$this->repo->expects($this->never())->method('insert');
$this->expectUserError($msg, fn() => $this->service(self::admin())->create($input + ['domain' => 'example.org', 'name' => 'Sales']));
}
public function testAddressMustBeFreeAndWithinQuota(): void
{
$lookup = $this->createMock(LookupRepository::class);
$lookup->method('isDomain')->willReturn(true);
$lookup->method('addressTaken')->willReturnOnConsecutiveCalls(true, false);
$lookup->method('aliasesLeft')->willReturn(0);
$this->lookup = $lookup;
$this->repo->expects($this->never())->method('insert');
$service = $this->service(self::admin());
$this->expectUserError(['is_alias_or_mailbox', 'sales@example.org'], fn() => $service->create(['domain' => 'example.org', 'name' => 'S', 'local_part' => 'sales']));
$this->expectUserError('max_alias_exceeded', fn() => $service->create(['domain' => 'example.org', 'name' => 'S', 'local_part' => 'sales']));
}
public function testNameIsUniquePerDomain(): void
{
$this->repo->method('nameExists')->willReturn(true);
$this->expectUserError(['object_exists', 'Sales'], fn() => $this->service(self::admin())->create(['domain' => 'example.org', 'name' => 'Sales']));
}
public function testDomainAdminScope(): void
{
$service = $this->service(self::domainAdmin());
$this->expectUserError('access_denied', fn() => $service->create(['domain' => 'other.org', 'name' => 'X']));
$this->expectUserError('access_denied', fn() => $service->create(['domain' => 'example.org', 'name' => 'X', 'members' => ['c@other.org']]));
$this->expectUserError('access_denied', fn() => $service->create(['domain' => 'example.org', 'name' => 'X', 'send_as' => '@other.org']));
}
public function testAdminFieldsAreIgnoredForDomainAdmins(): void
{
$this->repo->expects($this->once())->method('insert')->with($this->callback(fn(Group $g): bool =>
$g->templateId === null && $g->priority === 0 && $g->idpRef === null))->willReturn([]);
$this->service(self::domainAdmin())->create(['domain' => 'example.org', 'name' => 'X', 'template_id' => 3, 'priority' => 9, 'idp_ref' => 'x']);
}
public function testReplacingMembersKeepsIdpAndOutOfScopeMembers(): void
{
$this->repo->method('findVisible')->willReturn(self::stored([], [
'a@example.org' => 'manual', 'c@other.org' => 'manual', 'd@example.org' => 'idp',
]));
$this->repo->expects($this->once())->method('update')->with($this->callback(fn(Group $g): bool =>
$g->members === ['b@example.org' => 'manual', 'c@other.org' => 'manual', 'd@example.org' => 'idp']))->willReturn([]);
$this->service(self::domainAdmin())->update(7, ['members' => ['b@example.org']]);
}
public function testIdpMembersCannotBeRemoved(): void
{
$this->repo->method('findVisible')->willReturn(self::stored([], ['a@example.org' => 'idp']));
$this->repo->expects($this->never())->method('update');
$this->expectUserError(['group_member_idp', 'a@example.org'], fn() => $this->service(self::admin())->update(7, ['remove_members' => 'a@example.org']));
}
public function testUpdateKeepsMissingAttributes(): void
{
$stored = self::stored(['description' => 'Team', 'template_id' => 3, 'priority' => 4], ['a@example.org' => 'manual'], ['shared@example.org']);
$this->repo->method('findVisible')->willReturn($stored);
$this->repo->expects($this->once())->method('update')->with($this->callback(function (Group $g): bool {
$this->assertSame(['Team', 'sales@example.org', 0, 3, 4], [$g->description, $g->address, $g->active, $g->templateId, $g->priority]);
$this->assertSame(['a@example.org' => 'manual'], $g->members);
$this->assertSame(['shared@example.org'], $g->sendAs);
return true;
}))->willReturn([]);
$this->service(self::admin())->update(7, ['active' => '0']);
}
public function testRemovingTheAddressFreesIt(): void
{
$this->repo->method('findVisible')->willReturn(self::stored());
$this->repo->expects($this->once())->method('update')->with($this->callback(fn(Group $g): bool => $g->address === null))->willReturn([]);
$this->service(self::admin())->update(7, ['address' => '']);
}
public function testForeignSendAsMakesAGroupReadOnlyForDomainAdmins(): void
{
$group = self::stored([], ['a@example.org' => 'manual'], ['@other.org']);
$this->repo->method('findVisible')->willReturn($group);
$this->repo->expects($this->never())->method('update');
$service = $this->service(self::domainAdmin());
$this->assertFalse($service->toArray($group)['can_edit']);
$this->expectUserError('access_denied', fn() => $service->update(7, ['name' => 'X']));
}
public function testGrantsAreNormalisedAndApplied(): void
{
$this->repo->method('findVisible')->willReturn(self::stored());
$this->repo->expects($this->once())->method('update')->with($this->callback(function (Group $g): bool {
$this->assertSame([['owner' => 'a@example.org', 'rights' => 'full', 'subscribe' => 1], ['owner' => 'b@example.org', 'rights' => 'read', 'subscribe' => 0]],
$g->imapGrants);
$this->assertSame([['owner' => 'a@example.org', 'type' => 'Calendar', 'folder' => 'personal', 'access' => 'write', 'subscribe' => 1],
['owner' => 'a@example.org', 'type' => 'Contacts', 'folder' => 'team-1', 'access' => 'read', 'subscribe' => 0]], $g->sogoGrants);
return true;
}))->willReturn([]);
$this->sharing = $this->createMock(SharingService::class);
$this->sharing->expects($this->once())->method('applyImap')->with(7)->willReturn([['group_imap_grant_failed', 'b@example.org']]);
$this->sharing->expects($this->once())->method('applySogo')->with(7)->willReturn([]);
$group = $this->service(self::admin())->update(7, [
// a form sends JSON strings, an API client lists
'imap_grants' => '[{"owner":"B@example.org","rights":"read"},{"owner":"a@example.org","rights":"full","subscribe":1}]',
'sogo_grants' => [['owner' => 'a@example.org', 'type' => 'Contacts', 'folder' => 'team-1'],
['owner' => 'a@example.org', 'access' => 'write', 'subscribe' => '1']],
]);
$this->assertSame([['group_imap_grant_failed', 'b@example.org']], $group->warnings);
}
public static function invalidGrants(): array
{
return [
'unknown rights' => [['imap_grants' => [['owner' => 'a@example.org', 'rights' => 'admin']]], ['group_grant_invalid', 'a@example.org']],
'resource owner' => [['imap_grants' => [['owner' => 'room1@example.org']]], ['group_grant_invalid', 'room1@example.org']],
'no owner' => [['sogo_grants' => [['type' => 'Calendar']]], ['group_grant_invalid', '']],
'folder path' => [['sogo_grants' => [['owner' => 'a@example.org', 'folder' => '../x']]], ['group_grant_invalid', 'a@example.org']],
'unknown type' => [['sogo_grants' => [['owner' => 'a@example.org', 'type' => 'Mail']]], ['group_grant_invalid', 'a@example.org']],
'no list' => [['imap_grants' => 'a@example.org'], ['group_grant_invalid', '']],
'foreign owner for a domain admin' => [['imap_grants' => [['owner' => 'c@other.org']]], 'access_denied'],
];
}
#[DataProvider('invalidGrants')]
public function testInvalidGrantsAreRejected(array $input, string|array $msg): void
{
$this->repo->method('findVisible')->willReturn(self::stored());
$this->repo->expects($this->never())->method('update');
$this->expectUserError($msg, fn() => $this->service(self::domainAdmin())->update(7, $input));
}
public function testForeignGrantOwnersMakeAGroupReadOnlyForDomainAdmins(): void
{
$group = self::stored();
$group->sogoGrants = [['owner' => 'c@other.org', 'type' => 'Calendar', 'folder' => 'personal', 'access' => 'read']];
$this->assertFalse($this->service(self::domainAdmin())->canEdit($group));
$this->assertTrue($this->service(self::admin())->canEdit($group));
}
public function testDeleteRemovesTheImapAclsFirst(): void
{
$group = self::stored();
$this->repo->method('findVisible')->willReturn($group);
$calls = [];
$this->sharing->expects($this->once())->method('removeImap')->with($group)->willReturnCallback(function () use (&$calls): void {
$calls[] = 'acl';
});
$this->repo->method('delete')->willReturnCallback(function () use (&$calls): array {
$calls[] = 'delete';
return [];
});
$this->service(self::admin())->delete(7);
$this->assertSame(['acl', 'delete'], $calls);
}
public function testDeleteReportsWarningsOfTheEffects(): void
{
$effects = $this->createMock(ChangeEffects::class);
$effects->expects($this->once())->method('snapshot')->with(['a@example.org'])->willReturn(['a@example.org' => 'Staff']);
$effects->expects($this->once())->method('apply')->with(['a@example.org'], ['a@example.org' => 'Staff'])->willReturn([['w']]);
$this->effects = $effects;
$this->repo->method('findVisible')->willReturn(self::stored());
$this->repo->expects($this->once())->method('delete')->with(7)->willReturn(['a@example.org']);
$this->assertSame([['w']], $this->service(self::admin())->delete(7)->warnings);
}
public function testLargeDistributionGroupsWarn(): void
{
$members = [];
for ($i = 0; $i < 1001; $i++) {
$members[] = 'u' . $i . '@example.org';
}
$this->repo->method('insert')->willReturn([]);
$group = $this->service(self::admin())->create(['domain' => 'example.org', 'name' => 'All', 'local_part' => 'all', 'members' => $members]);
$this->assertSame([['group_too_many_members', 'All', 1000]], $group->warnings);
}
public function testMembershipsAreVisibleToTheMailboxAndItsManagers(): void
{
$this->lookup->method('membershipsOf')->willReturn([['id' => 7, 'name' => 'Sales']]);
$this->lookup->method('sendAsOf')->willReturn(['sales@example.org']);
$this->templates->method('effective')->willReturn(['a@example.org' => 'Staff']);
$expected = ['groups' => [['id' => 7, 'name' => 'Sales']], 'template' => 'Staff', 'send_as' => ['sales@example.org']];
$this->assertSame($expected, $this->service(FakeContext::user('a@example.org', []))->membershipsOf('A@example.org'));
$this->assertSame($expected, $this->service(self::domainAdmin())->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(FakeContext::user('b@example.org', []))->membershipsOf('a@example.org'));
$this->assertSame([], $this->service(self::domainAdmin())->membershipsOf('c@other.org'));
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Service;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\IdpSyncService;
use PHPUnit\Framework\TestCase;
class IdpSyncServiceTest extends TestCase
{
public function testOnlyIdpMembershipsAreReplaced(): void
{
$group = Group::fromRow(['id' => 7, 'domain' => 'example.org', 'name' => 'Sales']);
$group->members = ['a@example.org' => 'manual', 'b@example.org' => 'idp', 'c@example.org' => 'idp'];
$repo = $this->createMock(GroupRepository::class);
$repo->method('find')->with(7)->willReturn($group);
$repo->expects($this->once())->method('update')->with($this->callback(fn(Group $g): bool =>
$g->members === ['a@example.org' => 'manual', 'c@example.org' => 'idp', 'd@example.org' => 'idp']))->willReturn([]);
$lookup = $this->createMock(LookupRepository::class);
$lookup->method('regularMailboxes')->willReturnCallback(fn(array $u): array => array_values(array_diff($u, ['room@example.org'])));
$effects = $this->createMock(ChangeEffects::class);
$effects->method('apply')->willReturn([]);
$result = (new IdpSyncService($repo, $lookup, $effects))
->syncGroupMembers(7, ['A@example.org', 'c@example.org', 'd@example.org', 'room@example.org']);
$this->assertSame(['d@example.org'], $result['added']);
$this->assertSame(['b@example.org'], $result['removed']);
$this->assertSame(['room@example.org'], $result['skipped']);
}
public function testNothingIsWrittenWithoutChanges(): void
{
$group = Group::fromRow(['id' => 7]);
$group->members = ['a@example.org' => 'idp'];
$repo = $this->createMock(GroupRepository::class);
$repo->method('find')->willReturn($group);
$repo->expects($this->never())->method('update');
$lookup = $this->createMock(LookupRepository::class);
$lookup->method('regularMailboxes')->willReturn(['a@example.org']);
(new IdpSyncService($repo, $lookup, $this->createMock(ChangeEffects::class)))->syncGroupMembers(7, ['a@example.org']);
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Service;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\Sogo\Service\SogoService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SharingServiceTest extends TestCase
{
public function testGrantStatesFollowDovecot(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('imapGrantsToApply')->with(7, false)->willReturn([
self::grant(1, 'a@example.org', 'write', 'pending'),
self::grant(2, 'b@example.org', 'read', 'removing'),
self::grant(3, 'c@example.org', 'read', 'pending'),
self::grant(4, 'd@example.org', 'read', 'removing'),
]);
$dovecot = $this->createMock(DovecotService::class);
$dovecot->method('setAclAll')->willReturnCallback(fn(string $owner, string $identifier, array $rights): ?string =>
in_array($owner, ['c@example.org', 'd@example.org'], true) ? 'command failed' : null);
$states = [];
$repo->method('setImapState')->willReturnCallback(function (int $id, string $state, ?string $error) use (&$states): void {
$states[$id] = [$state, $error];
});
$repo->expects($this->once())->method('deleteImapGrant')->with(2);
$warnings = (new SharingService($repo, $dovecot, $this->createMock(SogoService::class), $this->createMock(ChangeEffects::class)))->applyImap(7);
$this->assertSame([1 => ['applied', null], 3 => ['failed', 'command failed'], 4 => ['removing', 'command failed']], $states);
$this->assertSame([['group_imap_grant_failed', 'c@example.org'], ['group_imap_grant_failed', 'd@example.org']], $warnings);
}
public function testRightsPerLevel(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('imapGrantsToApply')->willReturn([self::grant(1, 'a@example.org', 'read', 'pending')]);
$dovecot = $this->createMock(DovecotService::class);
$dovecot->expects($this->once())->method('setAclAll')->with('a@example.org', 'group=mcg_7', ['lookup', 'read', 'write-seen'])->willReturn(null);
(new SharingService($repo, $dovecot, $this->createMock(SogoService::class), $this->createMock(ChangeEffects::class)))->applyImap(7);
}
public function testRemovingAGroupDropsItsAcls(): void
{
$group = Group::fromRow(['id' => 7]);
$group->imapGrants = [['owner' => 'a@example.org', 'rights' => 'read']];
$dovecot = $this->createMock(DovecotService::class);
$dovecot->expects($this->once())->method('setAclAll')->with('a@example.org', 'group=mcg_7', []);
(new SharingService($this->createMock(GroupRepository::class), $dovecot, $this->createMock(SogoService::class), $this->createMock(ChangeEffects::class)))->removeImap($group);
}
public function testReconcileAppliesEverythingAgain(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->expects($this->once())->method('imapGrantsToApply')->with(null, true)->willReturn([]);
$repo->method('groupIdsWithSogoGrants')->willReturn([3, 5]);
$repo->method('rebuild')->willReturnCallback(fn(int $id): array => ($id === 3) ? ['a@example.org'] : ['a@example.org', 'b@example.org']);
$repo->expects($this->once())->method('cleanupSogoAcl');
$effects = $this->createMock(ChangeEffects::class);
$effects->expects($this->once())->method('apply')->with(['a@example.org', 'b@example.org'], [])->willReturn([]);
(new SharingService($repo, $this->createMock(DovecotService::class), $this->createMock(SogoService::class), $effects))->reconcile();
}
public function testMembersAreSubscribedOnce(): void
{
$repo = $this->reconcileRepo();
$repo->method('staleSubscriptions')->willReturn([]);
$repo->method('missingSubscriptions')->willReturnCallback(fn(string $kind): array => ($kind === 'imap')
? [['grant_id' => 2, 'owner' => 'team@example.org', 'folder' => '', 'usernames' => ['a@example.org']]]
: [['grant_id' => 3, 'owner' => 'team@example.org', 'folder' => 'Calendar/personal', 'usernames' => ['a@example.org', 'b@example.org']],
['grant_id' => 4, 'owner' => 'team@example.org', 'folder' => 'Contacts/personal', 'usernames' => ['c@example.org']]]);
$added = [];
$repo->method('addSubscriptions')->willReturnCallback(function (string $kind, int $grant, string $owner, string $folder, array $users) use (&$added): void {
$added[] = [$kind, $grant, $folder, $users];
});
$dovecot = $this->createMock(DovecotService::class);
$dovecot->expects($this->once())->method('subscribeShared')->with('team@example.org', ['a@example.org'], false)->willReturn(null);
$legacy = $this->createMock(SogoService::class);
$legacy->method('subscribe')->willReturnCallback(fn(string $owner, string $folder): ?string =>
($folder === 'Contacts/personal') ? 'command failed' : null);
$legacy->expects($this->once())->method('flushCache');
$warnings = (new SharingService($repo, $dovecot, $legacy, $this->effects()))->reconcile();
// the failed batch is not recorded, so the next run tries again
$this->assertSame([['imap', 2, '', ['a@example.org']], ['sogo', 3, 'Calendar/personal', ['a@example.org', 'b@example.org']]], $added);
$this->assertSame([['group_subscription_failed', 'c@example.org']], $warnings);
}
public function testSubscriptionsOfMembersWithoutAccessAreRemoved(): void
{
$repo = $this->reconcileRepo();
$repo->method('missingSubscriptions')->willReturn([]);
$repo->method('staleSubscriptions')->willReturnCallback(fn(string $kind): array => ($kind === 'imap')
? [['id' => 1, 'username' => 'a@example.org', 'owner' => 'team@example.org', 'folder' => '', 'member_exists' => 1, 'owner_exists' => 1, 'covered' => 0],
['id' => 2, 'username' => 'gone@example.org', 'owner' => 'team@example.org', 'folder' => '', 'member_exists' => 0, 'owner_exists' => 1, 'covered' => 0]]
: [['id' => 3, 'username' => 'b@example.org', 'owner' => 'team@example.org', 'folder' => 'Calendar/personal', 'member_exists' => 1, 'owner_exists' => 1, 'covered' => 0],
// another group still grants the calendar
['id' => 4, 'username' => 'c@example.org', 'owner' => 'team@example.org', 'folder' => 'Calendar/personal', 'member_exists' => 1, 'owner_exists' => 1, 'covered' => 1]]);
$deleted = [];
$repo->method('deleteSubscription')->willReturnCallback(function (int $id) use (&$deleted): void {
$deleted[] = $id;
});
$dovecot = $this->createMock(DovecotService::class);
$dovecot->expects($this->once())->method('subscribeShared')->with('team@example.org', ['a@example.org'], true)->willReturn(null);
$legacy = $this->createMock(SogoService::class);
$legacy->expects($this->once())->method('subscribe')->with('team@example.org', 'Calendar/personal', ['b@example.org'], true)->willReturn('failed');
$legacy->expects($this->never())->method('flushCache');
$warnings = (new SharingService($repo, $dovecot, $legacy, $this->effects()))->reconcile();
$this->assertSame([1, 2, 4], $deleted);
$this->assertSame([['group_subscription_failed', 'b@example.org']], $warnings);
}
public function testMissingSogoFoldersAreCreatedAfterAWrite(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('pendingSogoOwners')->with(7)->willReturn(['team@example.org']);
$repo->expects($this->once())->method('rebuild')->with(7)->willReturn([]);
$legacy = $this->createMock(SogoService::class);
$legacy->expects($this->once())->method('initUsers')->with(['team@example.org'])->willReturn(null);
$this->assertSame([], (new SharingService($repo, $this->createMock(DovecotService::class), $legacy, $this->effects()))->applySogo(7));
}
public function testNothingHappensWithoutPendingFolders(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('pendingSogoOwners')->willReturn([]);
$repo->expects($this->never())->method('rebuild');
$legacy = $this->createMock(SogoService::class);
$legacy->expects($this->never())->method('initUsers');
$this->assertSame([], (new SharingService($repo, $this->createMock(DovecotService::class), $legacy, $this->effects()))->applySogo(7));
}
public function testFailedFolderCreationIsAWarning(): void
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('pendingSogoOwners')->willReturn(['team@example.org']);
$repo->expects($this->never())->method('rebuild');
$legacy = $this->createMock(SogoService::class);
$legacy->method('initUsers')->willReturn('command failed');
$this->assertSame([['group_sogo_init_failed', 'team@example.org']],
(new SharingService($repo, $this->createMock(DovecotService::class), $legacy, $this->effects()))->applySogo(7));
}
/** Repository mock for reconcile(): no IMAP grants, no SOGo grants, no pending folders */
private function reconcileRepo(): GroupRepository&MockObject
{
$repo = $this->createMock(GroupRepository::class);
$repo->method('imapGrantsToApply')->willReturn([]);
$repo->method('groupIdsWithSogoGrants')->willReturn([]);
$repo->method('pendingSogoOwners')->willReturn([]);
return $repo;
}
private function effects(): ChangeEffects&MockObject
{
$effects = $this->createMock(ChangeEffects::class);
$effects->method('apply')->willReturn([]);
return $effects;
}
private static function grant(int $id, string $owner, string $rights, string $state): array
{
return ['id' => $id, 'group_id' => 7, 'owner' => $owner, 'rights' => $rights, 'state' => $state, 'last_error' => null];
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\MailboxGroup\Service;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\TemplateService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class TemplateServiceTest extends TestCase
{
private LookupRepository&MockObject $lookup;
private LegacyMailbox&MockObject $legacy;
protected function setUp(): void
{
$this->lookup = $this->createMock(LookupRepository::class);
$this->legacy = $this->createMock(LegacyMailbox::class);
$this->lookup->method('mailboxNames')->willReturnCallback(fn(array $u): array => array_fill_keys($u, 'Name'));
}
private function service(): TemplateService
{
return new TemplateService($this->lookup, $this->legacy);
}
public function testOnlyChangedTemplatesAreApplied(): void
{
$this->lookup->method('effectiveTemplates')->willReturn([
'a@example.org' => 'Staff', // unchanged
'b@example.org' => 'Admins', // changed
'c@example.org' => null, // left all groups: settings stay
'd@example.org' => 'Staff', // joined
]);
$applied = [];
$this->legacy->method('applyTemplate')->willReturnCallback(function (string $u, string $name, string $t) use (&$applied): ?string {
$applied[] = [$u, $name, $t];
return ($u === 'd@example.org') ? 'mailbox_quota_exceeded' : null;
});
$warnings = $this->service()->applyChanged([
'a@example.org' => 'Staff', 'b@example.org' => 'Staff', 'c@example.org' => 'Staff', 'd@example.org' => null,
]);
$this->assertSame([['b@example.org', 'Name', 'Admins'], ['d@example.org', 'Name', 'Staff']], $applied);
$this->assertSame([['group_template_failed', 'Staff', 'd@example.org']], $warnings);
}
public function testLoginPrefersTheGroupTemplate(): void
{
$this->lookup->method('effectiveTemplates')->willReturnCallback(fn(array $u): array =>
($u === ['a@example.org']) ? ['a@example.org' => 'Staff'] : array_fill_keys($u, null));
$this->assertSame('Staff', $this->service()->templateForLogin('a@example.org', 'Legacy'));
$this->assertSame('Legacy', $this->service()->templateForLogin('b@example.org', 'Legacy'));
$this->assertNull($this->service()->templateForLogin('b@example.org', null));
}
public function testAnEditedTemplateIsAppliedWhereItIsEffective(): void
{
$this->lookup->method('templateName')->with(3)->willReturn('Staff');
$this->lookup->method('membersWithTemplate')->with(3)->willReturn(['a@example.org', 'b@example.org']);
$this->lookup->method('effectiveTemplates')->willReturn(['a@example.org' => 'Staff', 'b@example.org' => 'Admins']);
$this->legacy->expects($this->once())->method('applyTemplate')->with('a@example.org', 'Name', 'Staff')->willReturn(null);
$this->assertSame([], $this->service()->onTemplateChanged(3));
}
public function testReapplyUsesTheEffectiveTemplates(): void
{
$this->lookup->method('effectiveTemplates')->willReturn(['a@example.org' => 'Staff', 'b@example.org' => null]);
$this->legacy->expects($this->once())->method('applyTemplate')->with('a@example.org', 'Name', 'Staff')->willReturn(null);
$this->service()->applyTo(['a@example.org', 'b@example.org']);
}
}