createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn(['enabled' => 0]); $service = $this->newService($repository); $result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('sso_disabled', $result['error']); } public function testGetEffectiveMicrosoftProviderConfigRejectsInvalidTenantId(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => 'invalid', 'use_shared_app' => 1, ]); $service = $this->newService($repository); $result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('tenant_id_invalid', $result['error']); } public function testGetEffectiveMicrosoftProviderConfigRequiresSharedCredentials(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => 1, ]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn(''); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn(''); $service = $this->newService($repository, null, $settingsGateway); $result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('shared_credentials_missing', $result['error']); } public function testGetEffectiveMicrosoftProviderConfigRequiresOverrideClientId(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => 0, 'client_id_override' => '', 'client_secret_override_enc' => 'enc', ]); $service = $this->newService($repository); $result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('client_id_override_required', $result['error']); } public function testGetEffectiveMicrosoftProviderConfigRejectsInvalidOverrideSecret(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => 0, 'client_id_override' => 'client-id', 'client_secret_override_enc' => 'enc', ]); $cryptoGateway = $this->createMock(AuthCryptoGateway::class); $cryptoGateway->method('decryptString')->willThrowException(new \RuntimeException('invalid secret')); $service = $this->newService($repository, null, null, $cryptoGateway); $result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('secret_invalid', $result['error']); } public function testGetEffectiveMicrosoftProviderConfigReturnsConfigForOverrideCredentials(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'allowed_domains' => 'example.com', 'enforce_microsoft_login' => 1, 'sync_profile_on_login' => 1, 'sync_profile_fields' => 'first_name,avatar', 'use_shared_app' => 0, 'client_id_override' => 'client-id', 'client_secret_override_enc' => 'enc', ]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $cryptoGateway = $this->createMock(AuthCryptoGateway::class); $cryptoGateway->method('decryptString')->willReturn('secret-value'); $service = $this->newService($repository, null, $settingsGateway, $cryptoGateway); $result = $service->getEffectiveMicrosoftProviderConfig(['id' => 5]); $this->assertTrue($result['ok']); $this->assertSame('client-id', $result['config']['client_id']); $this->assertSame('secret-value', $result['config']['client_secret']); $this->assertTrue((bool) $result['config']['sync_profile_needs_graph']); $this->assertSame('https://login.microsoftonline.com/common/v2.0', $result['config']['authority']); $this->assertFalse((bool) $result['config']['use_shared_app']); } public function testResolveTenantLoginMethodsReturnsTenantNotFoundForInactiveTenant(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'inactive']); $service = $this->newService(null, $tenantGateway); $result = $service->resolveTenantLoginMethods(5); $this->assertFalse($result['local']); $this->assertFalse($result['microsoft']); $this->assertSame('tenant_not_found', $result['microsoft_reason']); } public function testResolveTenantLoginMethodsReturnsLocalOnlyWhenSsoDisabled(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn(['enabled' => 0]); $service = $this->newService($repository, $tenantGateway); $result = $service->resolveTenantLoginMethods(5); $this->assertTrue($result['local']); $this->assertFalse($result['microsoft']); $this->assertSame('sso_disabled', $result['microsoft_reason']); } public function testBuildMicrosoftUiStateReturnsLocalOnlyPasswordModeWhenSsoDisabled(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 0, 'enforce_microsoft_login' => 1, ]); $service = $this->newService($repository); $result = $service->buildMicrosoftUiState(5); $this->assertSame('local_only', $result['password_mode']); } public function testBuildMicrosoftUiStateReturnsLocalAndMicrosoftPasswordModeWhenSsoEnabledWithoutEnforcement(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'enforce_microsoft_login' => 0, ]); $service = $this->newService($repository); $result = $service->buildMicrosoftUiState(5); $this->assertSame('local_and_microsoft', $result['password_mode']); } public function testBuildMicrosoftUiStateReturnsMicrosoftOnlyPasswordModeWhenEnforced(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'enforce_microsoft_login' => 1, ]); $service = $this->newService($repository); $result = $service->buildMicrosoftUiState(5); $this->assertSame('microsoft_only', $result['password_mode']); } public function testResolveTenantLoginMethodsReturnsMicrosoftOnlyWhenEnforcedAndConfigured(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'enforce_microsoft_login' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => 1, ]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('shared-client'); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('shared-secret'); $settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $service = $this->newService($repository, $tenantGateway, $settingsGateway); $result = $service->resolveTenantLoginMethods(5); $this->assertFalse($result['local']); $this->assertTrue($result['microsoft']); $this->assertSame('', $result['microsoft_reason']); } public function testSaveTenantMicrosoftAuthReturnsErrorWhenTenantDoesNotExist(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(null); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->expects($this->never())->method('upsertByTenantId'); $service = $this->newService($repository, $tenantGateway); $result = $service->saveTenantMicrosoftAuth(5, ['microsoft_enabled' => '1']); $this->assertFalse($result['ok']); $this->assertNotEmpty($result['errors']); } public function testSaveTenantMicrosoftAuthValidatesCryptoWhenOverrideSecretIsProvided(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 0, 'use_shared_app' => 0, 'client_secret_override_enc' => '', ]); $repository->expects($this->never())->method('upsertByTenantId'); $cryptoGateway = $this->createMock(AuthCryptoGateway::class); $cryptoGateway->method('isConfigured')->willReturn(false); $service = $this->newService($repository, $tenantGateway, null, $cryptoGateway); $result = $service->saveTenantMicrosoftAuth(5, [ 'microsoft_enabled' => '1', 'use_shared_app' => '', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'client_id_override' => 'client-id', 'client_secret_override' => 'new-secret', ]); $this->assertFalse($result['ok']); $this->assertNotEmpty($result['errors']); } public function testSaveTenantMicrosoftAuthPersistsNormalizedSharedAppPayload(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'enabled' => 0, 'use_shared_app' => 1, 'client_secret_override_enc' => '', ]); $repository->expects($this->once()) ->method('upsertByTenantId') ->with( 5, $this->callback(static function (array $data): bool { return $data['enabled'] === true && $data['enforce_microsoft_login'] === true && $data['sync_profile_on_login'] === true && $data['sync_profile_fields'] === 'first_name,last_name' && $data['entra_tenant_id'] === '11111111-1111-1111-1111-111111111111' && $data['allowed_domains'] === 'example.com,test.org' && $data['use_shared_app'] === true && $data['client_id_override'] === null && $data['client_secret_override_enc'] === null; }) ) ->willReturn(true); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('shared-client'); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('shared-secret'); $service = $this->newService($repository, $tenantGateway, $settingsGateway); $result = $service->saveTenantMicrosoftAuth(5, [ 'microsoft_enabled' => '1', 'enforce_microsoft_login' => '1', 'sync_profile_on_login' => '1', 'sync_profile_fields' => [], 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'allowed_domains' => "example.com, invalid, test.org\nexample.com", 'use_shared_app' => '1', 'client_id_override' => 'ignored', 'client_secret_override' => '', ]); $this->assertTrue($result['ok']); } // --------------------------------------------------------------- // shouldAutoRememberOnMicrosoftLogin — policy resolution // --------------------------------------------------------------- public function testShouldAutoRememberReturnsTrueWhenForceOn(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'auto_remember_mode' => 1, ]); $service = $this->newService($repository); $this->assertTrue($service->shouldAutoRememberOnMicrosoftLogin(5)); } public function testShouldAutoRememberReturnsFalseWhenForceOff(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'auto_remember_mode' => 0, ]); $service = $this->newService($repository); $this->assertFalse($service->shouldAutoRememberOnMicrosoftLogin(5)); } public function testShouldAutoRememberInheritUsesGlobalDefaultTrue(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'auto_remember_mode' => null, ]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn(''); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn(''); $settingsGateway->method('getMicrosoftAuthority')->willReturn(''); $settingsGateway->method('isMicrosoftAutoRememberDefault')->willReturn(true); $service = $this->newService($repository, null, $settingsGateway); $this->assertTrue($service->shouldAutoRememberOnMicrosoftLogin(5)); } public function testShouldAutoRememberInheritUsesGlobalDefaultFalse(): void { $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([ 'auto_remember_mode' => null, ]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn(''); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn(''); $settingsGateway->method('getMicrosoftAuthority')->willReturn(''); $settingsGateway->method('isMicrosoftAutoRememberDefault')->willReturn(false); $service = $this->newService($repository, null, $settingsGateway); $this->assertFalse($service->shouldAutoRememberOnMicrosoftLogin(5)); } public function testSaveTenantMicrosoftAuthPersistsAutoRememberMode(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([]); $repository->expects($this->once()) ->method('upsertByTenantId') ->with( 5, $this->callback(static function (array $data): bool { return $data['auto_remember_mode'] === 1; }) ) ->willReturn(true); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id'); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('client-secret'); $settingsGateway->method('getMicrosoftAuthority')->willReturn(''); $service = $this->newService($repository, $tenantGateway, $settingsGateway); $result = $service->saveTenantMicrosoftAuth(5, [ 'microsoft_enabled' => '1', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => '1', 'sync_profile_on_login' => '', 'sync_profile_fields' => [], 'allowed_domains' => '', 'enforce_microsoft_login' => '', 'client_id_override' => '', 'client_secret_override' => '', 'microsoft_auto_remember_mode' => '1', ]); $this->assertTrue($result['ok']); } public function testSaveTenantMicrosoftAuthPersistsInheritAsNull(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $repository->method('findByTenantId')->willReturn([]); $repository->expects($this->once()) ->method('upsertByTenantId') ->with( 5, $this->callback(static function (array $data): bool { return $data['auto_remember_mode'] === null; }) ) ->willReturn(true); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id'); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('client-secret'); $settingsGateway->method('getMicrosoftAuthority')->willReturn(''); $service = $this->newService($repository, $tenantGateway, $settingsGateway); $result = $service->saveTenantMicrosoftAuth(5, [ 'microsoft_enabled' => '1', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => '1', 'sync_profile_on_login' => '', 'sync_profile_fields' => [], 'allowed_domains' => '', 'enforce_microsoft_login' => '', 'client_id_override' => '', 'client_secret_override' => '', 'microsoft_auto_remember_mode' => 'inherit', ]); $this->assertTrue($result['ok']); } // ── discoverTenantsByEmailDomain ──────────────────────────────── public function testDiscoverTenantsByEmailDomainReturnsMatchingMicrosoftTenant(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active', 'uuid' => 'abc', 'description' => 'Acme']); $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findEnabledWithExplicitDomain')->with('acme.com')->willReturn([ ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], ]); $microsoftRepo->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => 1, ]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); $ldapRepo->method('findByTenantId')->willReturn(['enabled' => 0]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id'); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('secret'); $settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $service = $this->newService($microsoftRepo, $tenantGateway, $settingsGateway, null, $ldapRepo); $result = $service->discoverTenantsByEmailDomain('user@acme.com'); $this->assertCount(1, $result); $this->assertSame(5, $result[0]['id']); $this->assertFalse($result[0]['methods']['local']); $this->assertTrue($result[0]['methods']['microsoft']); } public function testDiscoverTenantsByEmailDomainReturnsEmptyForUnknownDomain(): void { $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); $service = $this->newService($microsoftRepo, null, null, null, $ldapRepo); $result = $service->discoverTenantsByEmailDomain('user@unknown.org'); $this->assertSame([], $result); } public function testDiscoverTenantsByEmailDomainExcludesInactiveTenants(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'inactive']); $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], ]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); $result = $service->discoverTenantsByEmailDomain('user@acme.com'); $this->assertSame([], $result); } public function testDiscoverTenantsByEmailDomainForcesLocalFalse(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active', 'uuid' => 'abc', 'description' => 'Acme']); $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], ]); // Microsoft disabled, but LDAP should still work — and local must be false $microsoftRepo->method('findByTenantId')->willReturn(['enabled' => 0]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([ ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], ]); $ldapRepo->method('findByTenantId')->willReturn([ 'enabled' => 1, 'host' => 'ldap.acme.com', 'base_dn' => 'DC=acme,DC=com', 'bind_dn_enc' => '', 'bind_password_enc' => '', ]); $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); $result = $service->discoverTenantsByEmailDomain('user@acme.com'); $this->assertCount(1, $result); $this->assertFalse($result[0]['methods']['local']); $this->assertTrue($result[0]['methods']['ldap']); } public function testDiscoverTenantsByEmailDomainExcludesTenantWithNoFunctionalSso(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], ]); // Microsoft enabled but misconfigured (invalid tenant ID) $microsoftRepo->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => 'invalid', 'use_shared_app' => 1, ]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); $ldapRepo->method('findByTenantId')->willReturn(['enabled' => 0]); $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); $result = $service->discoverTenantsByEmailDomain('user@acme.com'); $this->assertSame([], $result); } public function testDiscoverTenantsByEmailDomainReturnsEmptyForInvalidEmail(): void { $service = $this->newService(); $this->assertSame([], $service->discoverTenantsByEmailDomain('not-an-email')); $this->assertSame([], $service->discoverTenantsByEmailDomain('')); } public function testDiscoverTenantsByEmailDomainReturnsMultipleTenants(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturnCallback(static function (int $id): ?array { return match ($id) { 5 => ['id' => 5, 'status' => 'active', 'uuid' => 'u5', 'description' => 'Tenant A'], 7 => ['id' => 7, 'status' => 'active', 'uuid' => 'u7', 'description' => 'Tenant B'], default => null, }; }); $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ ['tenant_id' => 5, 'allowed_domains' => 'shared.com'], ['tenant_id' => 7, 'allowed_domains' => 'shared.com'], ]); $microsoftRepo->method('findByTenantId')->willReturn([ 'enabled' => 1, 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', 'use_shared_app' => 1, ]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); $ldapRepo->method('findByTenantId')->willReturn(['enabled' => 0]); $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id'); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('secret'); $settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $service = $this->newService($microsoftRepo, $tenantGateway, $settingsGateway, null, $ldapRepo); $result = $service->discoverTenantsByEmailDomain('user@shared.com'); $this->assertCount(2, $result); $ids = array_column($result, 'id'); $this->assertContains(5, $ids); $this->assertContains(7, $ids); } // ── LDAP tests ──────────────────────────────────────────────────── public function testGetTenantLdapAuthReturnsDefaults(): void { $ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepository->method('findByTenantId')->willReturn(null); $service = $this->newService(null, null, null, null, $ldapRepository); $result = $service->getTenantLdapAuth(1); $this->assertFalse($result['enabled']); $this->assertFalse($result['enforce_ldap_login']); $this->assertSame('', $result['host']); $this->assertSame(389, $result['port']); $this->assertSame('starttls', $result['encryption_mode']); } public function testGetEffectiveLdapProviderConfigReturnsDisabledWhenNotEnabled(): void { $ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepository->method('findByTenantId')->willReturn(['enabled' => 0]); $service = $this->newService(null, null, null, null, $ldapRepository); $result = $service->getEffectiveLdapProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('ldap_disabled', $result['error']); } public function testGetEffectiveLdapProviderConfigReturnsHostMissing(): void { $ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'host' => '', 'base_dn' => 'DC=example,DC=com', ]); $service = $this->newService(null, null, null, null, $ldapRepository); $result = $service->getEffectiveLdapProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('ldap_host_missing', $result['error']); } public function testGetEffectiveLdapProviderConfigReturnsBaseDnMissing(): void { $ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'host' => 'ldap.example.com', 'base_dn' => '', ]); $service = $this->newService(null, null, null, null, $ldapRepository); $result = $service->getEffectiveLdapProviderConfig(['id' => 5]); $this->assertFalse($result['ok']); $this->assertSame('ldap_base_dn_missing', $result['error']); } public function testGetEffectiveLdapProviderConfigSuccessNoBindCredentials(): void { $ldapRepository = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepository->method('findByTenantId')->willReturn([ 'enabled' => 1, 'host' => 'ldap.example.com', 'port' => 389, 'encryption_mode' => 'starttls', 'base_dn' => 'DC=example,DC=com', 'bind_dn_enc' => '', 'bind_password_enc' => '', 'user_search_filter' => '(&(objectClass=user)(sAMAccountName=%s))', ]); $service = $this->newService(null, null, null, null, $ldapRepository); $result = $service->getEffectiveLdapProviderConfig(['id' => 5]); $this->assertTrue($result['ok']); $this->assertSame('ldap', $result['config']['provider']); $this->assertSame('ldap.example.com', $result['config']['host']); } public function testResolveTenantLoginMethodsIncludesLdap(): void { $tenantGateway = $this->createMock(AuthTenantGateway::class); $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $microsoftRepo->method('findByTenantId')->willReturn(['enabled' => 0]); $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); $ldapRepo->method('findByTenantId')->willReturn([ 'enabled' => 1, 'host' => 'ldap.example.com', 'base_dn' => 'DC=example,DC=com', 'bind_dn_enc' => '', 'bind_password_enc' => '', ]); $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); $result = $service->resolveTenantLoginMethods(5); $this->assertTrue($result['local']); $this->assertFalse($result['microsoft']); $this->assertTrue($result['ldap']); } public function testNormalizeLdapAttributeMapParsesJson(): void { $service = $this->newService(); $map = $service->normalizeLdapAttributeMap('{"first_name":"givenName","email":"mail"}'); $this->assertSame('givenName', $map['first_name']); $this->assertSame('mail', $map['email']); } public function testNormalizeLdapAttributeMapReturnsDefaultForInvalidJson(): void { $service = $this->newService(); $map = $service->normalizeLdapAttributeMap('not json'); $this->assertArrayHasKey('first_name', $map); $this->assertSame('givenName', $map['first_name']); } private function newService( ?TenantMicrosoftAuthRepositoryInterface $repository = null, ?AuthTenantGateway $tenantGateway = null, ?AuthSettingsGateway $settingsGateway = null, ?AuthCryptoGateway $cryptoGateway = null, ?TenantLdapAuthRepositoryInterface $ldapRepository = null ): TenantSsoService { $repository = $repository ?? $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); $ldapRepository = $ldapRepository ?? $this->createMock(TenantLdapAuthRepositoryInterface::class); $tenantGateway = $tenantGateway ?? $this->createMock(AuthTenantGateway::class); if ($settingsGateway === null) { $settingsGateway = $this->createMock(AuthSettingsGateway::class); $settingsGateway->method('getMicrosoftSharedClientId')->willReturn(''); $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn(''); $settingsGateway->method('getMicrosoftAuthority')->willReturn(''); } if ($cryptoGateway === null) { $cryptoGateway = $this->createMock(AuthCryptoGateway::class); $cryptoGateway->method('isConfigured')->willReturn(true); $cryptoGateway->method('decryptString')->willReturn('decrypted-secret'); $cryptoGateway->method('encryptString')->willReturn('encrypted-secret'); } return new TenantSsoService( $tenantGateway, $repository, $ldapRepository, $settingsGateway, $cryptoGateway ); } }