mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-28 05:05:41 +00:00
[Web][Tests] Add mailbox group, identity sync, auth, Dovecot and SOGo module tests
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Identity\Service;
|
||||
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
use Mailcow\Identity\Service\LinkService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class LinkServiceTest extends TestCase
|
||||
{
|
||||
public function testLinkedMailboxNeedsTheLinkedUser(): void
|
||||
{
|
||||
$repo = $this->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Identity\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
use Mailcow\Identity\Repository\RunRepository;
|
||||
use Mailcow\Identity\Service\StatusService;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StatusServiceTest extends TestCase
|
||||
{
|
||||
public function testAdminRequestsRuns(): void
|
||||
{
|
||||
$runs = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Identity\Service;
|
||||
|
||||
use Mailcow\Identity\Model\Account;
|
||||
use Mailcow\Identity\Model\DirectoryGroup;
|
||||
use Mailcow\Identity\Model\DirectoryUser;
|
||||
use Mailcow\Identity\Model\Listing;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Model\State;
|
||||
use Mailcow\Identity\Service\SyncPlanner;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SyncPlannerTest extends TestCase
|
||||
{
|
||||
private const SINCE = '2026-10-25 10:00:00';
|
||||
|
||||
public function testUnlinkedMailboxIsAdoptedByEmail(): void
|
||||
{
|
||||
$plan = $this->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Identity\Service;
|
||||
|
||||
use Mailcow\Identity\Directory\Directory;
|
||||
use Mailcow\Identity\Directory\DirectoryException;
|
||||
use Mailcow\Identity\Legacy\LegacyAccounts;
|
||||
use Mailcow\Identity\Model\Account;
|
||||
use Mailcow\Identity\Model\DirectoryGroup;
|
||||
use Mailcow\Identity\Model\DirectoryUser;
|
||||
use Mailcow\Identity\Model\Listing;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Repository\IdentityRepository;
|
||||
use Mailcow\Identity\Repository\RunRepository;
|
||||
use Mailcow\Identity\Service\SyncPlanner;
|
||||
use Mailcow\Identity\Service\SyncService;
|
||||
use Mailcow\MailboxGroup\Model\Group;
|
||||
use Mailcow\MailboxGroup\Service\IdpSyncService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
use Mailcow\Sogo\Service\SogoService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SyncServiceTest extends TestCase
|
||||
{
|
||||
private IdentityRepository&MockObject $repo;
|
||||
private RunRepository&MockObject $runs;
|
||||
private IdpSyncService&MockObject $groups;
|
||||
private TemplateService&MockObject $templates;
|
||||
private LegacyAccounts&MockObject $legacy;
|
||||
private SogoService&MockObject $sogo;
|
||||
private array $finished = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user