From ad86403b875975d2e6b08a5d49386f46c4c0b95c Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:15:00 +0100 Subject: [PATCH] [Web][Tests] Add mailbox group, identity sync, auth, Dovecot and SOGo module tests --- .../php/Auth/Service/IdpLoginServiceTest.php | 67 ++++ tests/php/Core/ApiResponseTest.php | 11 + tests/php/Core/ContextTest.php | 25 ++ .../Dovecot/Service/DovecotServiceTest.php | 60 +++ .../Directory/KeycloakDirectoryTest.php | 117 ++++++ .../Identity/Directory/LdapDirectoryTest.php | 153 ++++++++ .../php/Identity/Service/LinkServiceTest.php | 57 +++ .../Identity/Service/StatusServiceTest.php | 68 ++++ .../php/Identity/Service/SyncPlannerTest.php | 264 +++++++++++++ .../php/Identity/Service/SyncServiceTest.php | 185 +++++++++ .../MailboxGroupApiControllerTest.php | 136 +++++++ .../MailboxGroup/Legacy/LegacyMailboxTest.php | 70 ++++ .../Service/ChangeEffectsTest.php | 42 ++ .../MailboxGroup/Service/GroupServiceTest.php | 359 ++++++++++++++++++ .../Service/IdpSyncServiceTest.php | 49 +++ .../Service/SharingServiceTest.php | 182 +++++++++ .../Service/TemplateServiceTest.php | 79 ++++ tests/php/Sogo/Service/SogoServiceTest.php | 63 +++ tests/php/bootstrap.php | 10 + 19 files changed, 1997 insertions(+) create mode 100644 tests/php/Auth/Service/IdpLoginServiceTest.php create mode 100644 tests/php/Dovecot/Service/DovecotServiceTest.php create mode 100644 tests/php/Identity/Directory/KeycloakDirectoryTest.php create mode 100644 tests/php/Identity/Directory/LdapDirectoryTest.php create mode 100644 tests/php/Identity/Service/LinkServiceTest.php create mode 100644 tests/php/Identity/Service/StatusServiceTest.php create mode 100644 tests/php/Identity/Service/SyncPlannerTest.php create mode 100644 tests/php/Identity/Service/SyncServiceTest.php create mode 100644 tests/php/MailboxGroup/Controller/MailboxGroupApiControllerTest.php create mode 100644 tests/php/MailboxGroup/Legacy/LegacyMailboxTest.php create mode 100644 tests/php/MailboxGroup/Service/ChangeEffectsTest.php create mode 100644 tests/php/MailboxGroup/Service/GroupServiceTest.php create mode 100644 tests/php/MailboxGroup/Service/IdpSyncServiceTest.php create mode 100644 tests/php/MailboxGroup/Service/SharingServiceTest.php create mode 100644 tests/php/MailboxGroup/Service/TemplateServiceTest.php create mode 100644 tests/php/Sogo/Service/SogoServiceTest.php diff --git a/tests/php/Auth/Service/IdpLoginServiceTest.php b/tests/php/Auth/Service/IdpLoginServiceTest.php new file mode 100644 index 000000000..dbc5acd23 --- /dev/null +++ b/tests/php/Auth/Service/IdpLoginServiceTest.php @@ -0,0 +1,67 @@ +createMock(LinkService::class); + $links->method('verify')->willThrowException(new PDOException('gone')); + $links->method('mayCreate')->willThrowException(new PDOException('gone')); + $links->method('link')->willThrowException(new PDOException('gone')); + $login = new IdpLoginService(Settings::fromArray([]), $links, $this->createMock(TemplateService::class)); + + $this->assertFalse($login->verifyLink('keycloak', 'a@example.org', 'u1')); + $this->assertFalse($login->mayCreate('keycloak', 'a@example.org', 'u1')); + $login->storeLink('keycloak', 'a@example.org', 'u1'); + } + + public function testLinkChecksArePassedOn(): void + { + $links = $this->createMock(LinkService::class); + $links->method('verify')->with('ldap', 'a@example.org', 'guid-1')->willReturn(true); + $links->method('mayCreate')->with('ldap', 'b@example.org', 'guid-2')->willReturn(false); + $links->expects($this->once())->method('link')->with('ldap', 'c@example.org', 'guid-3'); + $login = new IdpLoginService(Settings::fromArray([]), $links, $this->createMock(TemplateService::class)); + + $this->assertTrue($login->verifyLink('ldap', 'a@example.org', 'guid-1')); + $this->assertFalse($login->mayCreate('ldap', 'b@example.org', 'guid-2')); + $login->storeLink('ldap', 'c@example.org', 'guid-3'); + } + + public function testGroupTemplateWinsAndErrorsFallBackToTheMapping(): void + { + $templates = $this->createMock(TemplateService::class); + $templates->method('templateForLogin')->willReturnCallback(function (string $username, ?string $mapped): ?string { + if ($username === 'broken@example.org') { + throw new PDOException('gone'); + } + return 'Group'; + }); + $login = new IdpLoginService(Settings::fromArray([]), $this->createMock(LinkService::class), $templates); + + $this->assertSame('Group', $login->templateFor('a@example.org', 'Staff')); + $this->assertSame('Staff', $login->templateFor('broken@example.org', 'Staff')); + } + + public function testMappingTakesSingleValuesAndLists(): void + { + $settings = Settings::fromArray(['mappers' => ['default', 'sales'], 'templates' => ['Staff', 'Sales']]); + $login = new IdpLoginService($settings, $this->createMock(LinkService::class), $this->createMock(TemplateService::class)); + + $this->assertSame('Staff', $login->mappedTemplate('default')); + $this->assertSame('Sales', $login->mappedTemplate(['other', 'sales'])); + $this->assertNull($login->mappedTemplate(null)); + $this->assertNull($login->mappedTemplate(['unknown'])); + } +} diff --git a/tests/php/Core/ApiResponseTest.php b/tests/php/Core/ApiResponseTest.php index c6c4a7503..789cfce18 100644 --- a/tests/php/Core/ApiResponseTest.php +++ b/tests/php/Core/ApiResponseTest.php @@ -29,6 +29,17 @@ class ApiResponseTest extends TestCase ], $response->messages()); } + public function testWarningsKeepTheCallSuccessful(): void + { + $response = new ApiResponse(); + + $response->warning(['group_template_failed', 'Staff', 'a@example.org'], ['mailbox_group', 'edit', 'group', []]); + + $this->assertSame([ + ['type' => 'warning', 'log' => ['mailbox_group', 'edit', 'group', []], 'msg' => ['group_template_failed', 'Staff', 'a@example.org']], + ], $response->messages()); + } + public function testSecretsAreMaskedInArrayLogParts(): void { $response = new ApiResponse(['password1', 'oauth_client_secret']); diff --git a/tests/php/Core/ContextTest.php b/tests/php/Core/ContextTest.php index 19217391f..1a4f2e7b4 100644 --- a/tests/php/Core/ContextTest.php +++ b/tests/php/Core/ContextTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Mailcow\Tests\Core; +use Mailcow\Core\AccessDenied; use Mailcow\Core\Context; use Mailcow\Tests\Support\LegacyAclCalls; use PHPUnit\Framework\TestCase; @@ -84,6 +85,30 @@ class ContextTest extends TestCase $this->assertFalse($context->hasAcl('syncjobs')); } + public function testAdminRightsComeFromTheRoleOrAccessAll(): void + { + $this->assertTrue((new Context('admin', 'admin'))->hasAdminRights()); + $this->assertTrue((new Context('user', 'user@example.org', [], true))->hasAdminRights()); + $this->assertFalse((new Context('domainadmin', 'da'))->hasAdminRights()); + $this->assertFalse(Context::system()->hasAdminRights()); + } + + public function testReadOnlyApiKeysMayOnlyRead(): void + { + $readOnly = new Context('admin', 'admin', [], false, 'ro'); + $readOnly->requireAdmin(false); + (new Context('admin', 'admin', [], false, 'rw'))->requireAdmin(); + + $this->expectException(AccessDenied::class); + $readOnly->requireAdmin(); + } + + public function testRequireAdminDeniesOtherRoles(): void + { + $this->expectException(AccessDenied::class); + (new Context('domainadmin', 'da'))->requireAdmin(false); + } + public function testBridgePassesUsernameAndRoleToTheLegacyFunctions(): void { $context = new Context('user', 'user@example.org'); diff --git a/tests/php/Dovecot/Service/DovecotServiceTest.php b/tests/php/Dovecot/Service/DovecotServiceTest.php new file mode 100644 index 000000000..2a4bacc1d --- /dev/null +++ b/tests/php/Dovecot/Service/DovecotServiceTest.php @@ -0,0 +1,60 @@ + 'success', 'msg' => 'command completed successfully']); + + $this->assertNull($dovecot->setAclAll('shared@example.org', 'group=mcg_7', ['lookup', 'read'])); + $this->assertSame([['cmd' => 'doveadm', 'task' => 'set_acl_all', 'user' => 'shared@example.org', 'id' => 'group=mcg_7', + 'rights' => ['lookup', 'read']]], $dovecot->calls); + } + + public function testErrorsOfDockerapiAreReturned(): void + { + $this->assertSame('command failed: no such user', (new FakeDovecot(['type' => 'danger', 'msg' => 'command failed: no such user'])) + ->setAclAll('x@example.org', 'group=mcg_7', [])); + $this->assertSame('dockerapi did not answer', (new FakeDovecot(false))->setAclAll('x@example.org', 'group=mcg_7', [])); + } + + public function testSharedFoldersAreSubscribedThroughDockerapi(): void + { + $dovecot = new FakeDovecot(['type' => 'success']); + + $this->assertNull($dovecot->subscribeShared('team@example.org', ['a@example.org'], true)); + $this->assertSame([['cmd' => 'doveadm', 'task' => 'subscribe_shared', 'owner' => 'team@example.org', 'users' => ['a@example.org'], + 'unsubscribe' => true]], $dovecot->calls); + } + + public function testAuthCacheFlushOnlyWithUsers(): void + { + $dovecot = new FakeDovecot([]); + $dovecot->flushAuthCache([]); + $dovecot->flushAuthCache(['a@example.org']); + + $this->assertSame([['cmd' => 'doveadm', 'task' => 'auth_cache_flush', 'users' => ['a@example.org']]], $dovecot->calls); + } +} + +class FakeDovecot extends DovecotService +{ + public array $calls = []; + + public function __construct(private mixed $result) + { + } + + protected function exec(array $fields): mixed + { + $this->calls[] = $fields; + return is_array($this->result) ? json_encode($this->result) : $this->result; + } +} diff --git a/tests/php/Identity/Directory/KeycloakDirectoryTest.php b/tests/php/Identity/Directory/KeycloakDirectoryTest.php new file mode 100644 index 000000000..9daaf5bdb --- /dev/null +++ b/tests/php/Identity/Directory/KeycloakDirectoryTest.php @@ -0,0 +1,117 @@ + "u$i", 'email' => "User$i@Example.org", 'firstName' => 'User', 'lastName' => (string)$i, 'enabled' => $i !== 2, + 'attributes' => ['mailcow_template' => ['staff']]]; + } + $client = new FakeKeycloakClient(['/users' => $users]); + + $listing = (new KeycloakDirectory($client, Settings::fromArray([])))->listAll(false); + + $this->assertCount(150, $listing->users); + $this->assertNull($listing->groups); + $this->assertSame('user1@example.org', $listing->users['u1']->email); + $this->assertSame('User 1', $listing->users['u1']->name); + $this->assertSame(['staff'], $listing->users['u1']->templateValues); + $this->assertFalse($listing->users['u2']->enabled); + $this->assertSame(['/users?briefRepresentation=false&first=0&max=100', '/users?briefRepresentation=false&first=100&max=100'], + $client->paths()); + } + + public function testSelectsGroupsByPathWithChildrenAndListedMembers(): void + { + $client = new FakeKeycloakClient([ + '/users' => [['id' => 'u1', 'email' => 'a@example.org', 'enabled' => true], ['id' => 'u2', 'email' => 'b@example.org', 'enabled' => true]], + '/groups' => [ + ['id' => 'g1', 'name' => 'mailcow', 'path' => '/mailcow', 'subGroupCount' => 2, 'subGroups' => []], + ['id' => 'g4', 'name' => 'Other', 'path' => '/Other', 'subGroups' => []], + ], + '/groups/g1/children' => [ + ['id' => 'g2', 'name' => 'Sales', 'path' => '/mailcow/Sales'], + ['id' => 'g3', 'name' => 'Support', 'path' => '/mailcow/Support', 'attributes' => ['mail' => ['Support@Example.org']]], + ], + '/groups/g2/members' => [['id' => 'u1'], ['id' => 'u2'], ['id' => 'not-listed']], + '/groups/g3/members' => [], + '/groups/g4/members' => [], + ]); + + $listing = (new KeycloakDirectory($client, Settings::fromArray(['group_paths' => '/mailcow/*, /Other'])))->listAll(true); + + $this->assertSame(['g2', 'g3', 'g4'], array_keys($listing->groups)); + $this->assertSame(['u1', 'u2'], $listing->groups['g2']->memberIds); + $this->assertSame('support@example.org', $listing->groups['g3']->mail); + $this->assertSame('Other', $listing->groups['g4']->name); + } + + public function testRejectedTokenIsRenewedOnce(): void + { + $client = new FakeKeycloakClient(['/users' => []], 1); + + (new KeycloakDirectory($client, Settings::fromArray([])))->listAll(false); + + $this->assertSame(2, $client->tokens); + } + + public function testHttpErrorsAbortTheListing(): void + { + $client = new FakeKeycloakClient(['/users' => 500]); + + $this->expectException(DirectoryException::class); + $this->expectExceptionMessage('Keycloak returned HTTP 500 for /users'); + (new KeycloakDirectory($client, Settings::fromArray([])))->listAll(false); + } +} + +/** + * Keycloak admin API in memory: routes are paths below the realm with a list (paged by first/max) or an HTTP status. + */ +class FakeKeycloakClient extends KeycloakClient +{ + public int $tokens = 0; + private array $requests = []; + + public function __construct(private array $routes, private int $rejectTokens = 0) + { + parent::__construct('https://kc.example.org', 'mailcow', 'mailcow', 'secret'); + } + + /** @return string[] requested paths with query, token requests excluded */ + public function paths(): array + { + return $this->requests; + } + + protected function send(string $method, string $url, array $headers, ?string $body): array + { + if (str_ends_with($url, '/protocol/openid-connect/token')) { + $this->tokens++; + return [200, json_encode(['access_token' => 'token' . $this->tokens])]; + } + if ($this->rejectTokens > 0 && in_array('Authorization: Bearer token1', $headers, true)) { + return [401, '']; + } + $path = substr((string)parse_url($url, PHP_URL_PATH), strlen('/admin/realms/mailcow')); + parse_str((string)parse_url($url, PHP_URL_QUERY), $query); + $this->requests[] = $path . '?' . http_build_query($query); + $route = $this->routes[$path] ?? 404; + if (is_int($route)) { + return [$route, '']; + } + return [200, json_encode(array_slice($route, (int)($query['first'] ?? 0), (int)($query['max'] ?? count($route))))]; + } +} diff --git a/tests/php/Identity/Directory/LdapDirectoryTest.php b/tests/php/Identity/Directory/LdapDirectoryTest.php new file mode 100644 index 000000000..5504dc9ae --- /dev/null +++ b/tests/php/Identity/Directory/LdapDirectoryTest.php @@ -0,0 +1,153 @@ + self::BASE, 'filter' => 'objectClass=person', 'disabled_filter' => '(employeeType=left)'], + function (string $base, string $filter): array { + if (str_contains($filter, 'employeeType=left')) { + return [self::entry('cn=c,ou=people,dc=example,dc=org', [])]; + } + return [ + self::entry('CN=A,ou=people,dc=example,dc=org', ['mail' => 'A@example.org', 'displayname' => 'A User', + 'objectguid' => hex2bin('0123456789abcdef0123456789abcdef'), 'useraccountcontrol' => '512']), + self::entry('cn=b,ou=people,dc=example,dc=org', ['mail' => 'b@example.org', 'cn' => 'B', + 'entryuuid' => 'F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6', 'useraccountcontrol' => '514']), + self::entry('cn=c,ou=people,dc=example,dc=org', ['mail' => 'c@example.org']), + ]; + }); + + $users = $directory->listAll(false)->users; + + $this->assertSame(['67452301-ab89-efcd-0123-456789abcdef', 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6', 'dn:cn=c,ou=people,dc=example,dc=org'], + array_keys($users)); + $user = $users['67452301-ab89-efcd-0123-456789abcdef']; + $this->assertSame(['a@example.org', 'A User', true], [$user->email, $user->name, $user->enabled]); + $this->assertFalse($users['f81d4fae-7dec-11d0-a765-00a0c91e6bf6']->enabled); + $this->assertFalse($users['dn:cn=c,ou=people,dc=example,dc=org']->enabled); + $this->assertSame('(&(&(objectClass=person)(mail=*))(employeeType=left))', $directory->searches[0][1]); + $this->assertSame('(&(objectClass=person)(mail=*))', $directory->searches[1][1]); + } + + public function testConfiguredIdAttributeWins(): void + { + $entry = self::entry('cn=a,dc=example,dc=org', ['employeenumber' => 'E-17', 'entryuuid' => 'x']); + + $this->assertSame('e-17', LdapDirectory::entryId($entry, 'employeenumber')); + $this->assertSame('x', LdapDirectory::entryId($entry)); + } + + public function testMembersFromMemberOf(): void + { + $directory = new FakeLdapDirectory(['basedn' => self::BASE, 'group_filter' => '(objectClass=groupOfNames)'], + function (string $base, string $filter, array $attributes): array { + if ($filter === '(objectClass=groupOfNames)') { + $this->assertContains('mail', $attributes); + return [self::entry('CN=Sales,ou=groups,dc=example,dc=org', ['cn' => 'Sales', 'entryuuid' => 'g1', 'mail' => 'Sales@example.org'])]; + } + $this->assertContains('memberof', $attributes); + return [ + self::entry('cn=a,dc=example,dc=org', ['mail' => 'a@example.org', 'entryuuid' => 'u1', 'memberof' => ['cn=sales,ou=groups,dc=example,dc=org']]), + self::entry('cn=b,dc=example,dc=org', ['mail' => 'b@example.org', 'entryuuid' => 'u2', 'memberof' => ['cn=other,dc=example,dc=org']]), + ]; + }); + + $groups = $directory->listAll(true)->groups; + + $this->assertSame(['g1'], array_keys($groups)); + $this->assertSame(['Sales', 'sales@example.org', ['u1']], [$groups['g1']->name, $groups['g1']->mail, $groups['g1']->memberIds]); + } + + public function testMembersFromTheMemberAttribute(): void + { + $directory = new FakeLdapDirectory(['basedn' => self::BASE, 'group_filter' => 'objectClass=groupOfNames', 'group_member_mode' => 'member'], + fn(string $base, string $filter): array => ($filter === '(objectClass=groupOfNames)') + ? [self::entry('cn=sales,dc=example,dc=org', ['cn' => 'Sales', 'entryuuid' => 'g1', + 'member' => ['CN=A,dc=example,dc=org', 'cn=unknown,dc=example,dc=org']])] + : [self::entry('cn=a,dc=example,dc=org', ['mail' => 'a@example.org', 'entryuuid' => 'u1'])]); + + $this->assertSame(['u1'], $directory->listAll(true)->groups['g1']->memberIds); + } + + public function testRangedMemberListsAbort(): void + { + $directory = new FakeLdapDirectory(['basedn' => self::BASE, 'group_filter' => '(objectClass=group)', 'group_member_mode' => 'member'], + fn(string $base, string $filter): array => ($filter === '(objectClass=group)') + ? [self::entry('cn=big,dc=example,dc=org', ['cn' => 'Big', 'member;range=0-1499' => ['cn=a,dc=example,dc=org']])] + : []); + + $this->expectException(DirectoryException::class); + $directory->listAll(true); + } + + public function testNestedMembersFromTheAdInChainRule(): void + { + $directory = new FakeLdapDirectory(['basedn' => self::BASE, 'group_basedn' => 'ou=groups,dc=example,dc=org', + 'group_filter' => '(objectClass=group)', 'group_member_mode' => 'ad_chain'], + function (string $base, string $filter): array { + if ($filter === '(objectClass=group)') { + $this->assertSame('ou=groups,dc=example,dc=org', $base); + return [self::entry('cn=sales (de),ou=groups,dc=example,dc=org', ['cn' => 'Sales', 'objectguid' => 'g1'])]; + } + if (str_contains($filter, '1.2.840.113556.1.4.1941')) { + $this->assertSame('(&(&(objectClass=*)(mail=*))(memberOf:1.2.840.113556.1.4.1941:=cn=sales \\28de\\29,ou=groups,dc=example,dc=org))', $filter); + return [self::entry('cn=a,dc=example,dc=org', [])]; + } + return [self::entry('cn=a,dc=example,dc=org', ['mail' => 'a@example.org', 'entryuuid' => 'u1'])]; + }); + + $this->assertSame(['u1'], $directory->listAll(true)->groups['g1']->memberIds); + } + + public function testGroupsNeedAFilter(): void + { + $directory = new FakeLdapDirectory(['basedn' => self::BASE], fn(): array => []); + + $this->expectException(DirectoryException::class); + $directory->listAll(true); + } + + /** An entry as ldap_get_entries() returns it */ + private static function entry(string $dn, array $attributes): array + { + $entry = ['dn' => $dn]; + foreach ($attributes as $name => $values) { + $values = (array)$values; + $entry[$name] = $values + ['count' => count($values)]; + } + return $entry + ['count' => count($attributes)]; + } +} + +/** + * LdapDirectory with canned search results instead of an LDAP server. + */ +class FakeLdapDirectory extends LdapDirectory +{ + /** @var array[] [base, filter, attributes] of every search */ + public array $searches = []; + + public function __construct(array $settings, private Closure $results) + { + parent::__construct(null, Settings::fromArray($settings + ['authsource' => 'ldap'])); + } + + protected function search(string $base, string $filter, array $attributes): array + { + $this->searches[] = [$base, $filter, $attributes]; + return ($this->results)($base, $filter, $attributes); + } +} diff --git a/tests/php/Identity/Service/LinkServiceTest.php b/tests/php/Identity/Service/LinkServiceTest.php new file mode 100644 index 000000000..32a9725b2 --- /dev/null +++ b/tests/php/Identity/Service/LinkServiceTest.php @@ -0,0 +1,57 @@ +createMock(IdentityRepository::class); + $repo->method('findLink')->willReturn(['provider' => 'keycloak', 'external_id' => 'u1', 'idp_disabled' => 0, 'missing_since' => null]); + $repo->expects($this->never())->method('link'); + $login = new LinkService($repo); + + $this->assertTrue($login->verify('keycloak', 'a@example.org', 'u1')); + $this->assertFalse($login->verify('keycloak', 'a@example.org', 'u2')); + $this->assertFalse($login->verify('keycloak', 'a@example.org', '')); + } + + public function testUnlinkedMailboxIsLinkedOnLogin(): void + { + $repo = $this->createMock(IdentityRepository::class); + $repo->method('findLink')->willReturn(['provider' => 'keycloak', 'external_id' => null, 'idp_disabled' => 1, 'missing_since' => null]); + $repo->method('usernameOf')->willReturn(null); + $repo->expects($this->once())->method('link')->with('a@example.org', 'keycloak', 'u1'); + + $this->assertTrue((new LinkService($repo))->verify('keycloak', 'a@example.org', 'u1')); + } + + public function testUserOfAnotherMailboxIsDenied(): void + { + $repo = $this->createMock(IdentityRepository::class); + $repo->method('findLink')->willReturn(null); + $repo->method('usernameOf')->with('ldap', 'guid-1')->willReturn('old@example.org'); + $repo->expects($this->never())->method('link'); + $login = new LinkService($repo); + + $this->assertFalse($login->verify('ldap', 'new@example.org', 'guid-1')); + $this->assertFalse($login->mayCreate('ldap', 'new@example.org', 'guid-1')); + $this->assertTrue($login->mayCreate('ldap', 'old@example.org', 'guid-1')); + } + + public function testLinkOfAnotherProviderIsReplaced(): void + { + $repo = $this->createMock(IdentityRepository::class); + $repo->method('findLink')->willReturn(['provider' => 'ldap', 'external_id' => 'guid-1', 'idp_disabled' => 0, 'missing_since' => null]); + $repo->method('usernameOf')->willReturn(null); + $repo->expects($this->once())->method('link')->with('a@example.org', 'keycloak', 'u1'); + + $this->assertTrue((new LinkService($repo))->verify('keycloak', 'a@example.org', 'u1')); + } +} diff --git a/tests/php/Identity/Service/StatusServiceTest.php b/tests/php/Identity/Service/StatusServiceTest.php new file mode 100644 index 000000000..92f51810e --- /dev/null +++ b/tests/php/Identity/Service/StatusServiceTest.php @@ -0,0 +1,68 @@ +createMock(RunRepository::class); + $runs->expects($this->once())->method('request')->with('keycloak', 'force', 'admin')->willReturn(5); + + $this->assertSame(5, $this->service(FakeContext::admin(), $runs)->requestRun('force')); + } + + public function testInvalidModeIsRejected(): void + { + $this->expectExceptionObject(new UserError('iam_sync_mode_invalid')); + $this->service(FakeContext::admin())->requestRun(['run']); + } + + public function testReadOnlyApiMayOnlyRead(): void + { + $service = $this->service(new Context('admin', 'api', [], false, 'ro')); + + $this->assertSame('keycloak', $service->status()['provider']); + $this->expectException(AccessDenied::class); + $service->requestRun('run'); + } + + public function testDomainAdminsSeeOnlyLinksOfTheirMailboxes(): void + { + $repo = $this->createMock(IdentityRepository::class); + $repo->method('findLink')->willReturn(['provider' => 'keycloak', 'external_id' => 'u1', 'idp_disabled' => 0, 'missing_since' => null]); + $service = $this->service(FakeContext::domainAdmin(['a@example.org']), null, $repo); + + $this->assertSame('u1', $service->link('A@example.org')['external_id']); + $this->assertNull($service->link('b@example.org')); + $this->expectException(AccessDenied::class); + $service->status(); + } + + public function testDomainAdminsCannotUnlink(): void + { + $repo = $this->createMock(IdentityRepository::class); + $repo->expects($this->never())->method('unlink'); + + $this->expectException(AccessDenied::class); + $this->service(FakeContext::domainAdmin(['a@example.org']), null, $repo)->unlink('a@example.org'); + } + + private function service(Context $context, ?RunRepository $runs = null, ?IdentityRepository $repo = null): StatusService + { + return new StatusService(Settings::fromArray(['authsource' => 'keycloak']), $repo ?? $this->createMock(IdentityRepository::class), + $runs ?? $this->createMock(RunRepository::class), $context); + } +} diff --git a/tests/php/Identity/Service/SyncPlannerTest.php b/tests/php/Identity/Service/SyncPlannerTest.php new file mode 100644 index 000000000..54a5c4c10 --- /dev/null +++ b/tests/php/Identity/Service/SyncPlannerTest.php @@ -0,0 +1,264 @@ +plan([self::user('u1', 'a@example.org')], [new Account('a@example.org', 'A', 1)]); + + $this->assertSame(['a@example.org' => 'u1'], $plan->links); + $this->assertSame([], $plan->conflicts); + } + + public function testLinkedUserWithNewEmailIsAConflictAndNotCreated(): void + { + $plan = $this->plan([self::user('u1', 'new@example.org')], [new Account('a@example.org', 'A', 1, 'u1')]); + + $this->assertSame([], $plan->creates); + $this->assertSame([], $plan->missing); + $this->assertSame(['a@example.org is linked to an IdP user whose email is now new@example.org'], $plan->conflicts); + } + + public function testMailboxLinkedToAnotherUserIsNotTakenOver(): void + { + $plan = $this->plan([self::user('u2', 'a@example.org')], [new Account('a@example.org', 'A', 1, 'u1', false, self::SINCE)]); + + $this->assertSame([], $plan->links); + $this->assertSame(['a@example.org is linked to another IdP user'], $plan->conflicts); + // u1 is gone for the second run: login disabled + $this->assertSame(['a@example.org'], $plan->deactivate); + } + + public function testCreatesOnlyEnabledUsersOfMailcowDomainsWithTemplate(): void + { + $settings = ['mappers' => ['staff'], 'templates' => ['Staff']]; + $users = [ + self::user('u1', 'new@example.org', true, ['staff'], 'New User'), + self::user('u2', 'off@example.org', false, ['staff']), + self::user('u3', 'x@foreign.org', true, ['staff']), + self::user('u4', 'local@example.org', true, ['staff']), + self::user('u5', 'none@example.org'), + self::user('u6', null), + ]; + $plan = $this->plan($users, [], $settings, ['mailboxes' => ['local@example.org' => 'mailcow']]); + + $this->assertSame([['email' => 'new@example.org', 'name' => 'New User', 'template' => 'Staff', 'id' => 'u1']], $plan->creates); + $this->assertSame(['No template matches none@example.org'], $plan->conflicts); + $this->assertSame(4, $plan->skipped); + } + + public function testNothingIsCreatedWithoutImport(): void + { + $plan = $this->plan([self::user('u1', 'new@example.org')], [], ['import_users' => 0, 'default_template' => 'Default']); + + $this->assertSame([], $plan->creates); + } + + public function testDuplicateEmailsAreAConflict(): void + { + $plan = $this->plan([self::user('u1', 'a@example.org'), self::user('u2', 'a@example.org')], [], ['default_template' => 'Default']); + + $this->assertSame([], $plan->creates); + $this->assertSame(['Several IdP users have the email a@example.org'], $plan->conflicts); + } + + public function testNewMailboxGetsTheTemplateOfItsHighestPriorityGroup(): void + { + $groups = [self::group(1, 'g1', 'Sales', [], 'Sales', 5), self::group(2, 'g2', 'Board', [], 'Board', 10)]; + $listing = [new DirectoryGroup('g1', 'Sales', null, ['u1']), new DirectoryGroup('g2', 'Board', null, ['u1'])]; + $plan = $this->plan([self::user('u1', 'new@example.org', true, ['staff'])], [], + ['group_sync' => 1, 'mappers' => ['staff'], 'templates' => ['Staff']], ['groups' => $groups], $listing); + + $this->assertSame('Board', $plan->creates[0]['template']); + $this->assertSame(['g1' => ['new@example.org'], 'g2' => ['new@example.org']], $plan->memberships); + } + + /** + * @param ?bool $listed null = not listed, else enabled in the IdP + */ + #[DataProvider('offboardingCases')] + public function testOffboardingStates(int $active, bool $idpDisabled, ?string $missingSince, ?bool $listed, bool $offboarding, array $expected): void + { + $users = ($listed === null) ? [] : [self::user('u1', 'a@example.org', $listed)]; + $plan = $this->plan($users, [new Account('a@example.org', 'A', $active, 'u1', $idpDisabled, $missingSince)], + ['offboarding' => $offboarding ? 1 : 0]); + + foreach (['deactivate', 'restore', 'release', 'missing', 'found'] as $kind) { + $this->assertSame(in_array($kind, $expected, true) ? ['a@example.org'] : [], $plan->$kind, $kind); + } + } + + public static function offboardingCases(): array + { + return [ + 'disabled in the IdP' => [1, false, null, false, true, ['deactivate']], + 'activated by an admin after the sync disabled it' => [1, true, null, false, true, []], + 'disabled by an admin' => [2, false, null, false, true, []], + 'inactive' => [0, false, null, false, true, []], + 'enabled again' => [2, true, null, true, true, ['restore']], + 'enabled again, active changed by an admin' => [0, true, null, true, true, ['release']], + 'not listed for the first time' => [1, false, null, null, true, ['missing']], + 'not listed for the second time' => [1, false, self::SINCE, null, true, ['deactivate']], + 'listed again' => [1, false, self::SINCE, true, true, ['found']], + 'listed again after removal' => [2, true, self::SINCE, true, true, ['found', 'restore']], + 'offboarding off' => [1, false, null, false, false, []], + 'offboarding off tracks missing users' => [1, false, null, null, false, ['missing']], + ]; + } + + public function testGuardBlocksMassDeactivationUnlessForced(): void + { + $accounts = []; + $users = []; + for ($i = 1; $i <= 6; $i++) { + $accounts[] = new Account("u$i@example.org", '', 1, "u$i"); + $users[] = self::user("u$i", "u$i@example.org", $i > 5); + } + $this->assertFalse($this->plan($users, $accounts)->blocked); + + $users[5] = self::user('u6', 'u6@example.org', false); + $this->assertTrue($this->plan($users, $accounts)->blocked); + $this->assertFalse($this->plan($users, $accounts, [], [], null, true)->blocked); + } + + public function testGuardCountsMembershipsOfOrphanedGroups(): void + { + $members = []; + for ($i = 1; $i <= 6; $i++) { + $members["u$i@example.org"] = 'idp'; + } + $plan = $this->plan([], [], ['group_sync' => 1], ['groups' => [self::group(3, 'gone', 'Old', $members)]], []); + + $this->assertSame([3], $plan->orphanedGroups); + $this->assertSame(6, $plan->membershipRemovals); + $this->assertTrue($plan->blocked); + } + + public function testGroupMembershipsFollowTheIdp(): void + { + $accounts = [new Account('a@example.org', '', 1, 'u1'), new Account('b@example.org', '', 1, 'u2'), + new Account('c@example.org', '', 1, 'u3'), new Account('gone@example.org', '', 1, 'u4')]; + $members = ['a@example.org' => 'manual', 'b@example.org' => 'idp', 'gone@example.org' => 'idp']; + $plan = $this->plan([self::user('u1', 'a@example.org'), self::user('u2', 'b@example.org'), self::user('u3', 'c@example.org')], + $accounts, ['group_sync' => 1], ['groups' => [self::group(1, 'g1', 'Sales', $members)]], + [new DirectoryGroup('g1', 'Sales', null, ['u1', 'u3'])]); + + // b leaves, c joins, a stays manual, gone@ is not listed and keeps its membership + $this->assertSame(['g1' => ['a@example.org', 'c@example.org', 'gone@example.org']], $plan->memberships); + $this->assertSame(1, $plan->membershipRemovals); + } + + public function testUnchangedMembershipsArePlannedAsNothing(): void + { + $plan = $this->plan([self::user('u1', 'a@example.org')], [new Account('a@example.org', '', 1, 'u1')], ['group_sync' => 1], + ['groups' => [self::group(1, 'g1', 'Sales', ['a@example.org' => 'manual'])]], [new DirectoryGroup('g1', 'Sales', null, ['u1'])]); + + $this->assertSame([], $plan->memberships); + } + + public function testNewGroupsGoToTheDomainOfTheirMailOrTheDefaultDomain(): void + { + $groups = [ + new DirectoryGroup('g1', 'Sales', 'sales@sub.example.org'), + new DirectoryGroup('g2', 'Staff', 'staff@foreign.org'), + new DirectoryGroup('g3', 'Board'), + new DirectoryGroup('g4', 'Support'), + ]; + $state = ['domains' => ['example.org', 'sub.example.org'], 'groups' => [self::group(9, null, 'support', [])]]; + $plan = $this->plan([], [], ['group_sync' => 1, 'group_autocreate' => 1, 'group_default_domain' => 'example.org'], $state, $groups); + + $this->assertSame([ + ['ref' => 'g1', 'name' => 'Sales', 'domain' => 'sub.example.org', 'mail' => 'sales@sub.example.org'], + ['ref' => 'g2', 'name' => 'Staff', 'domain' => 'example.org', 'mail' => 'staff@foreign.org'], + ['ref' => 'g3', 'name' => 'Board', 'domain' => 'example.org', 'mail' => null], + ], $plan->groupCreates); + $this->assertSame(['A group Support exists in example.org and is not linked to the IdP group'], $plan->conflicts); + + $plan = $this->plan([], [], ['group_sync' => 1, 'group_autocreate' => 1], $state, [new DirectoryGroup('g3', 'Board')]); + $this->assertSame(['IdP group Board has no mailcow domain'], $plan->conflicts); + + $plan = $this->plan([], [], ['group_sync' => 1, 'group_autocreate' => 0, 'group_default_domain' => 'example.org'], $state, $groups); + $this->assertSame([], $plan->groupCreates); + } + + public function testRenamedIdpGroupsAreRenamedUnlessTheNameIsTaken(): void + { + $state = ['groups' => [self::group(1, 'g1', 'Sales', []), self::group(2, 'g2', 'Support', []), self::group(3, null, 'Board', [])]]; + $plan = $this->plan([], [], ['group_sync' => 1], $state, + [new DirectoryGroup('g1', 'Sales DE'), new DirectoryGroup('g2', 'board')]); + + $this->assertSame([1 => 'Sales DE'], $plan->groupRenames); + $this->assertSame(['IdP group Support was renamed to board, which exists in example.org'], $plan->conflicts); + } + + public function testUpdatesNameAndTemplateWhenTheyDiffer(): void + { + $settings = ['periodic_sync' => 1, 'mappers' => ['staff'], 'templates' => ['Staff']]; + $accounts = [new Account('a@example.org', 'A', 1, 'u1', false, null, 'h-staff'), + new Account('b@example.org', 'B', 1, 'u2', false, null, 'h-old'), + new Account('c@example.org', 'C', 1, 'u3', false, null, 'h-staff'), + new Account('d@example.org', 'D', 1, 'u4', false, null, 'h-staff')]; + $users = [self::user('u1', 'a@example.org', true, ['staff'], 'A'), self::user('u2', 'b@example.org', true, ['staff'], 'B'), + self::user('u3', 'c@example.org', true, ['staff'], 'C New'), self::user('u4', 'd@example.org', true, ['staff'])]; + $state = ['hashes' => ['Staff' => 'h-staff', 'Sales' => 'h-sales'], 'effective' => ['a@example.org' => 'Sales']]; + $plan = $this->plan($users, $accounts, $settings, $state); + + $this->assertSame([ + ['username' => 'a@example.org', 'name' => 'A', 'template' => 'Staff'], + ['username' => 'b@example.org', 'name' => 'B', 'template' => 'Staff'], + ['username' => 'c@example.org', 'name' => 'C New', 'template' => 'Staff'], + ], $plan->updates); + } + + private function plan(array $users, array $accounts, array $settings = [], array $state = [], ?array $groups = null, bool $force = false) + { + $byId = []; + foreach ($users as $user) { + $byId[$user->id] = $user; + } + $groupsById = null; + if ($groups !== null) { + $groupsById = []; + foreach ($groups as $group) { + $groupsById[$group->id] = $group; + } + } + $byName = []; + foreach ($accounts as $account) { + $byName[$account->username] = $account; + } + return (new SyncPlanner())->plan( + Settings::fromArray($settings + ['authsource' => 'keycloak', 'offboarding' => 1, 'import_users' => 1]), + new Listing($byId, $groupsById), + new State($byName, array_fill_keys(array_keys($byName), 'keycloak') + ($state['mailboxes'] ?? []), + $state['domains'] ?? ['example.org'], $state['groups'] ?? [], $state['effective'] ?? [], $state['hashes'] ?? []), + $force); + } + + private static function user(string $id, ?string $email, bool $enabled = true, array $templates = [], string $name = ''): DirectoryUser + { + return new DirectoryUser($id, $email, $name, $enabled, $templates); + } + + private static function group(int $id, ?string $ref, string $name, array $members, ?string $template = null, int $priority = 0): array + { + return ['id' => $id, 'domain' => 'example.org', 'name' => $name, 'idp_ref' => $ref, 'template' => $template, + 'priority' => $priority, 'active' => 1, 'members' => $members]; + } +} diff --git a/tests/php/Identity/Service/SyncServiceTest.php b/tests/php/Identity/Service/SyncServiceTest.php new file mode 100644 index 000000000..ea5f995c3 --- /dev/null +++ b/tests/php/Identity/Service/SyncServiceTest.php @@ -0,0 +1,185 @@ +repo = $this->createMock(IdentityRepository::class); + $this->repo->method('accounts')->willReturn(['a@example.org' => new Account('a@example.org', 'A', 1, 'u1'), + 'b@example.org' => new Account('b@example.org', 'B', 1)]); + $this->repo->method('mailboxes')->willReturn(['a@example.org' => 'keycloak', 'b@example.org' => 'keycloak']); + $this->repo->method('domains')->willReturn(['example.org']); + $this->runs = $this->createMock(RunRepository::class); + $this->runs->method('finish')->willReturnCallback(function (int $id, string $status, array $summary): void { + $this->finished = [$id, $status, $summary]; + }); + $this->groups = $this->createMock(IdpSyncService::class); + $this->groups->method('groups')->willReturn([['id' => 4, 'domain' => 'example.org', 'name' => 'Sales', 'idp_ref' => 'g1', + 'template' => null, 'priority' => 0, 'active' => 1, 'members' => []]]); + $this->templates = $this->createMock(TemplateService::class); + $this->templates->method('effective')->willReturn([]); + $this->legacy = $this->createMock(LegacyAccounts::class); + $this->sogo = $this->createMock(SogoService::class); + } + + public function testRunAppliesThePlan(): void + { + $this->repo->expects($this->exactly(2))->method('link')->willReturnCallback(fn(string $u, string $p, string $id) => + $this->assertContains([$u, $p, $id], [['b@example.org', 'keycloak', 'u2'], ['new@example.org', 'keycloak', 'u3']])); + $this->repo->expects($this->once())->method('setActive')->with('a@example.org', 'keycloak', 2, true); + $this->legacy->expects($this->once())->method('createMailbox')->with('new@example.org', 'New', 'keycloak', 'Default')->willReturn(null); + $this->sogo->expects($this->once())->method('refreshStaticView')->with(['a@example.org']); + $group = Group::fromRow(['id' => 4, 'name' => 'Sales']); + $this->groups->method('findByIdpRef')->with('g1')->willReturn($group); + $this->groups->expects($this->once())->method('syncGroupMembers')->with(4, ['b@example.org', 'new@example.org']) + ->willReturn(['added' => [], 'removed' => [], 'skipped' => [], 'warnings' => []]); + + $run = $this->service($this->listing())->execute(9, 'run'); + + $this->assertSame('ok', $run['status']); + $this->assertSame([], $run['summary']['errors']); + $this->assertSame([9, 'ok'], array_slice($this->finished, 0, 2)); + } + + public function testLeavingATemplateGroupAppliesTheMappingInTheSameRun(): void + { + $this->repo = $this->createMock(IdentityRepository::class); + $this->repo->method('accounts')->willReturn(['a@example.org' => new Account('a@example.org', 'A', 1, 'u1', false, null, 'h-default')]); + $this->repo->method('templateHashes')->willReturn(['Default' => 'h-default', 'Staff' => 'h-staff']); + $this->groups = $this->createMock(IdpSyncService::class); + $this->groups->method('groups')->willReturn([['id' => 4, 'domain' => 'example.org', 'name' => 'Sales', 'idp_ref' => 'g1', + 'template' => 'Default', 'priority' => 0, 'active' => 1, 'members' => ['a@example.org' => 'idp']]]); + $this->groups->method('findByIdpRef')->willReturn(Group::fromRow(['id' => 4, 'name' => 'Sales'])); + $this->groups->method('syncGroupMembers')->with(4, []) + ->willReturn(['added' => [], 'removed' => ['a@example.org'], 'skipped' => [], 'warnings' => []]); + $this->templates = $this->createMock(TemplateService::class); + $this->templates->method('effective')->willReturnOnConsecutiveCalls(['a@example.org' => 'Default'], ['a@example.org' => null]); + $this->legacy->expects($this->once())->method('applyTemplate')->with('a@example.org', 'A', 'Staff')->willReturn(null); + $directory = $this->createMock(Directory::class); + $directory->method('listAll')->willReturn(new Listing(['u1' => new DirectoryUser('u1', 'a@example.org', 'A', true, ['staff'])], + ['g1' => new DirectoryGroup('g1', 'Sales')])); + + $run = $this->service($directory, ['periodic_sync' => 1, 'mappers' => ['staff'], 'templates' => ['Staff']])->execute(9, 'run'); + + $this->assertSame(1, $run['summary']['counts']['updates']); + } + + public function testPreviewWritesNothing(): void + { + $this->repo->expects($this->never())->method('link'); + $this->repo->expects($this->never())->method('setActive'); + $this->legacy->expects($this->never())->method('createMailbox'); + $this->groups->expects($this->never())->method('syncGroupMembers'); + + $run = $this->service($this->listing())->execute(9, 'preview'); + + $this->assertSame(['ok', 'preview', 1, 1], [$run['status'], $run['summary']['mode'], $run['summary']['counts']['deactivate'], + $run['summary']['counts']['creates']]); + } + + public function testListingErrorsWriteNothing(): void + { + $this->repo->expects($this->never())->method('setMissing'); + $directory = $this->createMock(Directory::class); + $directory->method('listAll')->willThrowException(new DirectoryException('LDAP search incomplete (code 4): Sizelimit exceeded')); + + $run = $this->service($directory)->execute(9, 'run'); + + $this->assertSame('error', $run['status']); + $this->assertSame('LDAP search incomplete (code 4): Sizelimit exceeded', $this->finished[2]['error']); + } + + public function testBlockedRunsWriteNothing(): void + { + $this->repo->expects($this->never())->method('link'); + $this->repo->expects($this->never())->method('setActive'); + + $run = $this->service($this->listing(), ['offboarding_limit' => 0, 'group_sync' => 0], 6)->execute(9, 'run'); + + $this->assertSame('blocked', $run['status']); + } + + public function testScheduledRunsWaitForTheInterval(): void + { + $this->runs->method('nextPending')->willReturn(null); + $this->runs->method('secondsSinceScheduled')->willReturn(600); + $this->runs->expects($this->never())->method('create'); + + $this->assertNull($this->service($this->listing())->runDue()); + } + + public function testRequestedRunsComeFirst(): void + { + $this->runs->method('nextPending')->willReturn(['id' => 3, 'mode' => 'preview']); + $this->runs->expects($this->once())->method('start')->with(3); + + $this->assertSame('preview', $this->service($this->listing())->runDue()['mode']); + } + + public function testNothingIsDueWithoutSync(): void + { + $this->runs->method('nextPending')->willReturn(null); + $this->runs->expects($this->never())->method('create'); + + $this->assertNull($this->service($this->listing(), ['offboarding' => 0, 'group_sync' => 0, 'import_users' => 0])->runDue()); + } + + /** a@ (linked) is disabled, b@ is adopted, new@ is created, b@ and new@ are in Sales */ + private function listing(): Directory + { + $directory = $this->createMock(Directory::class); + $directory->method('listAll')->willReturn(new Listing([ + 'u1' => new DirectoryUser('u1', 'a@example.org', 'A', false), + 'u2' => new DirectoryUser('u2', 'b@example.org', 'B', true), + 'u3' => new DirectoryUser('u3', 'new@example.org', 'New', true), + ], ['g1' => new DirectoryGroup('g1', 'Sales', null, ['u2', 'u3'])])); + return $directory; + } + + private function service(Directory $directory, array $settings = [], int $extraAccounts = 0): SyncService + { + if ($extraAccounts > 0) { + $accounts = []; + for ($i = 0; $i < $extraAccounts; $i++) { + $accounts["x$i@example.org"] = new Account("x$i@example.org", '', 1, "x$i", false, '2026-10-25 10:00:00'); + } + $this->repo = $this->createMock(IdentityRepository::class); + $this->repo->method('accounts')->willReturn($accounts); + $this->repo->expects($this->never())->method('setActive'); + } + return new SyncService(Settings::fromArray($settings + ['authsource' => 'keycloak', 'offboarding' => 1, 'import_users' => 1, + 'group_sync' => 1, 'default_template' => 'Default']), fn(): Directory => $directory, $this->repo, $this->runs, new SyncPlanner(), + $this->groups, $this->templates, $this->legacy, $this->sogo); + } +} diff --git a/tests/php/MailboxGroup/Controller/MailboxGroupApiControllerTest.php b/tests/php/MailboxGroup/Controller/MailboxGroupApiControllerTest.php new file mode 100644 index 000000000..dfe74ad36 --- /dev/null +++ b/tests/php/MailboxGroup/Controller/MailboxGroupApiControllerTest.php @@ -0,0 +1,136 @@ +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('', [['group_template_failed', 'Staff', 'a@example.org']])); + + $this->assertTrue($this->api->addGroup(['name' => ''])); + + $this->assertSame([ + ['warning', ['group_template_failed', 'Staff', 'a@example.org']], + ['success', ['group_added', '<Sales>']], + ], $this->messages()); + $this->assertSame(['mailbox_group', 'add', 'group', ['name' => '']], $_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()); + } +} diff --git a/tests/php/MailboxGroup/Legacy/LegacyMailboxTest.php b/tests/php/MailboxGroup/Legacy/LegacyMailboxTest.php new file mode 100644 index 000000000..1bb1691e0 --- /dev/null +++ b/tests/php/MailboxGroup/Legacy/LegacyMailboxTest.php @@ -0,0 +1,70 @@ + [['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); + } +} diff --git a/tests/php/MailboxGroup/Service/ChangeEffectsTest.php b/tests/php/MailboxGroup/Service/ChangeEffectsTest.php new file mode 100644 index 000000000..8346ad8e8 --- /dev/null +++ b/tests/php/MailboxGroup/Service/ChangeEffectsTest.php @@ -0,0 +1,42 @@ +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([], []); + } +} diff --git a/tests/php/MailboxGroup/Service/GroupServiceTest.php b/tests/php/MailboxGroup/Service/GroupServiceTest.php new file mode 100644 index 000000000..731e656cc --- /dev/null +++ b/tests/php/MailboxGroup/Service/GroupServiceTest.php @@ -0,0 +1,359 @@ + '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')); + } +} diff --git a/tests/php/MailboxGroup/Service/IdpSyncServiceTest.php b/tests/php/MailboxGroup/Service/IdpSyncServiceTest.php new file mode 100644 index 000000000..7d79331c1 --- /dev/null +++ b/tests/php/MailboxGroup/Service/IdpSyncServiceTest.php @@ -0,0 +1,49 @@ + 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']); + } +} diff --git a/tests/php/MailboxGroup/Service/SharingServiceTest.php b/tests/php/MailboxGroup/Service/SharingServiceTest.php new file mode 100644 index 000000000..11206cca5 --- /dev/null +++ b/tests/php/MailboxGroup/Service/SharingServiceTest.php @@ -0,0 +1,182 @@ +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]; + } +} diff --git a/tests/php/MailboxGroup/Service/TemplateServiceTest.php b/tests/php/MailboxGroup/Service/TemplateServiceTest.php new file mode 100644 index 000000000..921fac29c --- /dev/null +++ b/tests/php/MailboxGroup/Service/TemplateServiceTest.php @@ -0,0 +1,79 @@ +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']); + } +} diff --git a/tests/php/Sogo/Service/SogoServiceTest.php b/tests/php/Sogo/Service/SogoServiceTest.php new file mode 100644 index 000000000..7f840c468 --- /dev/null +++ b/tests/php/Sogo/Service/SogoServiceTest.php @@ -0,0 +1,63 @@ + 'success']); + + $this->assertNull($sogo->subscribe('team@example.org', 'Calendar/personal', ['a@example.org'])); + $this->assertSame('dockerapi did not answer', (new FakeSogo(false))->subscribe('team@example.org', 'Calendar/personal', ['a@example.org'], true)); + $this->assertSame([['cmd' => 'sogo', 'task' => 'subscribe', 'owner' => 'team@example.org', 'folder' => 'Calendar/personal', + 'users' => ['a@example.org'], 'unsubscribe' => false]], $sogo->calls); + } + + public function testFoldersAreCreatedThroughDockerapi(): void + { + $sogo = new FakeSogo(['type' => 'success']); + + $this->assertNull($sogo->initUsers(['team@example.org'])); + $this->assertSame([['cmd' => 'sogo', 'task' => 'init_user', 'users' => ['team@example.org']]], $sogo->calls); + } + + public function testManyChangesRebuildTheWholeView(): void + { + $few = new FakeSogo(true); + $few->refreshStaticView(['a@example.org', 'a@example.org', 'b@example.org']); + $many = new FakeSogo(true); + $many->refreshStaticView(array_map(fn(int $i): string => 'u' . $i . '@example.org', range(1, 26))); + $none = new FakeSogo(true); + $none->refreshStaticView([]); + + $this->assertSame([['view', 'a@example.org'], ['view', 'b@example.org']], $few->calls); + $this->assertSame([['view', null]], $many->calls); + $this->assertSame([], $none->calls); + } +} + +class FakeSogo extends SogoService +{ + public array $calls = []; + + public function __construct(private mixed $result) + { + } + + protected function updateStaticView(?string $username): void + { + $this->calls[] = ['view', $username]; + } + + protected function exec(array $fields): mixed + { + $this->calls[] = $fields; + return is_array($this->result) ? json_encode($this->result) : $this->result; + } +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php index b5f93e966..1df5e57f4 100644 --- a/tests/php/bootstrap.php +++ b/tests/php/bootstrap.php @@ -6,3 +6,13 @@ declare(strict_types=1); // of data/web/inc/lib/composer.json, exactly as php-fpm does. require __DIR__ . '/../../data/web/inc/lib/vendor/autoload.php'; require __DIR__ . '/vendor/autoload.php'; + +// The php:8.2-cli test image has no intl (php-fpm has); ASCII domains are all the unit tests need +if (!function_exists('idn_to_ascii')) { + define('INTL_IDNA_VARIANT_UTS46', 1); + + function idn_to_ascii(string $domain, int $flags = 0, int $variant = INTL_IDNA_VARIANT_UTS46): string|false + { + return preg_match('/^[\x21-\x7e]*$/', $domain) ? $domain : false; + } +}