[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,117 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Identity\Directory;
use Mailcow\Identity\Directory\DirectoryException;
use Mailcow\Identity\Directory\KeycloakDirectory;
use Mailcow\Identity\Http\KeycloakClient;
use Mailcow\Identity\Model\Settings;
use PHPUnit\Framework\TestCase;
class KeycloakDirectoryTest extends TestCase
{
public function testListsAllPagesOfUsers(): void
{
$users = [];
for ($i = 1; $i <= 150; $i++) {
$users[] = ['id' => "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))))];
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Identity\Directory;
use Closure;
use Mailcow\Identity\Directory\DirectoryException;
use Mailcow\Identity\Directory\LdapDirectory;
use Mailcow\Identity\Model\Settings;
use PHPUnit\Framework\TestCase;
class LdapDirectoryTest extends TestCase
{
private const BASE = 'dc=example,dc=org';
public function testUsersWithIdsAndDisabledStates(): void
{
$directory = new FakeLdapDirectory(['basedn' => 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);
}
}