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']); } private function newService( ?TenantMicrosoftAuthRepositoryInterface $repository = null, ?AuthTenantGateway $tenantGateway = null, ?AuthSettingsGateway $settingsGateway = null, ?AuthCryptoGateway $cryptoGateway = null ): TenantSsoService { $repository = $repository ?? $this->createMock(TenantMicrosoftAuthRepositoryInterface::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, $settingsGateway, $cryptoGateway ); } }