createMock(SettingsMicrosoftGateway::class); $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); $microsoftGateway->method('setMicrosoftSharedClientSecret')->willReturn(true); $microsoftGateway->expects($this->once()) ->method('setMicrosoftAuthority') ->with('https://login.microsoftonline.com/tenants/v2.0') ->willReturn(true); $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); $result = $service->updateFromAdmin($this->validPostData([ 'microsoft_authority' => 'https://login.microsoftonline.com/tenants/v2.0', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidMicrosoftAuthority(): void { $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); $microsoftGateway->expects($this->once()) ->method('setMicrosoftAuthority') ->with('http://not-https.example.com') ->willReturn(false); $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); $result = $service->updateFromAdmin($this->validPostData([ 'microsoft_authority' => 'http://not-https.example.com', ])); $this->assertContains('microsoft_authority_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // Microsoft client secret // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidMicrosoftClientSecret(): void { $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); $microsoftGateway->expects($this->once()) ->method('setMicrosoftSharedClientSecret') ->with('my-secret-value') ->willReturn(true); $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); $result = $service->updateFromAdmin($this->validPostData([ 'microsoft_shared_client_secret' => 'my-secret-value', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorWhenClientSecretEncryptionFails(): void { $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $microsoftGateway->method('setMicrosoftSharedClientId')->willReturn(true); $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); $microsoftGateway->expects($this->once()) ->method('setMicrosoftSharedClientSecret') ->with('fail-secret') ->willReturn(false); $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); $result = $service->updateFromAdmin($this->validPostData([ 'microsoft_shared_client_secret' => 'fail-secret', ])); $this->assertContains('microsoft_client_secret_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // Microsoft client ID // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidMicrosoftClientId(): void { $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); $microsoftGateway->method('setMicrosoftSharedClientSecret')->willReturn(true); $microsoftGateway->expects($this->once()) ->method('setMicrosoftSharedClientId') ->with('some-client-id') ->willReturn(true); $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); $result = $service->updateFromAdmin($this->validPostData([ 'microsoft_shared_client_id' => 'some-client-id', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidMicrosoftClientId(): void { $microsoftGateway = $this->createMock(SettingsMicrosoftGateway::class); $microsoftGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); $microsoftGateway->method('setMicrosoftAuthority')->willReturn(true); $microsoftGateway->expects($this->once()) ->method('setMicrosoftSharedClientId') ->willReturn(false); $service = $this->newService(settingsMicrosoftGateway: $microsoftGateway); $result = $service->updateFromAdmin($this->validPostData([ 'microsoft_shared_client_id' => 'bad-id', ])); $this->assertContains('microsoft_client_id_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // System audit enabled // --------------------------------------------------------------- public function testUpdateFromAdminPersistsSystemAuditEnabled(): void { $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); $auditGateway->method('isSystemAuditEnabled')->willReturn(false); $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); $auditGateway->expects($this->once()) ->method('setSystemAuditEnabled') ->with(true) ->willReturn(true); $auditGateway->method('setSystemAuditRetentionDays')->willReturn(true); $service = $this->newService(settingsSystemAuditGateway: $auditGateway); $result = $service->updateFromAdmin($this->validPostData([ 'system_audit_enabled' => '1', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminPersistsSystemAuditDisabled(): void { $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); $auditGateway->method('isSystemAuditEnabled')->willReturn(true); $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); $auditGateway->expects($this->once()) ->method('setSystemAuditEnabled') ->with(false) ->willReturn(true); $auditGateway->method('setSystemAuditRetentionDays')->willReturn(true); $service = $this->newService(settingsSystemAuditGateway: $auditGateway); $post = $this->validPostData(); unset($post['system_audit_enabled']); $result = $service->updateFromAdmin($post); $this->assertSame([], $result['errors'] ?? []); } // --------------------------------------------------------------- // System audit retention // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidSystemAuditRetention(): void { $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); $auditGateway->method('isSystemAuditEnabled')->willReturn(true); $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); $auditGateway->method('setSystemAuditEnabled')->willReturn(true); $auditGateway->expects($this->once()) ->method('setSystemAuditRetentionDays') ->with(90) ->willReturn(true); $service = $this->newService(settingsSystemAuditGateway: $auditGateway); $result = $service->updateFromAdmin($this->validPostData([ 'system_audit_retention_days' => '90', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidSystemAuditRetention(): void { $auditGateway = $this->createMock(SettingsSystemAuditGateway::class); $auditGateway->method('isSystemAuditEnabled')->willReturn(true); $auditGateway->method('getSystemAuditRetentionDays')->willReturn(365); $auditGateway->method('setSystemAuditEnabled')->willReturn(true); $auditGateway->expects($this->once()) ->method('setSystemAuditRetentionDays') ->willReturn(false); $service = $this->newService(settingsSystemAuditGateway: $auditGateway); $result = $service->updateFromAdmin($this->validPostData([ 'system_audit_retention_days' => '99999', ])); $this->assertContains('system_audit_retention_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // Frontend telemetry enabled // --------------------------------------------------------------- public function testUpdateFromAdminPersistsFrontendTelemetryEnabled(): void { $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(false); $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); $telemetryGateway->expects($this->once()) ->method('setFrontendTelemetryEnabled') ->with(true) ->willReturn(true); $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); $result = $service->updateFromAdmin($this->validPostData([ 'frontend_telemetry_enabled' => '1', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminPersistsFrontendTelemetryDisabled(): void { $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); $telemetryGateway->expects($this->once()) ->method('setFrontendTelemetryEnabled') ->with(false) ->willReturn(true); $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); $post = $this->validPostData(); unset($post['frontend_telemetry_enabled']); $result = $service->updateFromAdmin($post); $this->assertSame([], $result['errors'] ?? []); } // --------------------------------------------------------------- // Frontend telemetry sample rate // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidFrontendTelemetrySampleRate(): void { $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->expects($this->once()) ->method('setFrontendTelemetrySampleRate') ->with(0.2) ->willReturn(true); $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); $result = $service->updateFromAdmin($this->validPostData([ 'frontend_telemetry_sample_rate' => '0.2', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForNonNumericSampleRate(): void { $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->expects($this->once()) ->method('setFrontendTelemetrySampleRate') ->willReturn(false); $telemetryGateway->method('setFrontendTelemetryAllowedEvents')->willReturn(true); $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); $result = $service->updateFromAdmin($this->validPostData([ 'frontend_telemetry_sample_rate' => 'not-a-number', ])); $this->assertContains('frontend_telemetry_sample_rate_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // Frontend telemetry allowed events // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidFrontendTelemetryAllowedEvents(): void { $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); $telemetryGateway->expects($this->once()) ->method('setFrontendTelemetryAllowedEvents') ->with(['warn_once', 'error']) ->willReturn(true); $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); $result = $service->updateFromAdmin($this->validPostData([ 'frontend_telemetry_allowed_events' => ['warn_once', 'error'], ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidFrontendTelemetryAllowedEvents(): void { $telemetryGateway = $this->createMock(SettingsFrontendTelemetryGateway::class); $telemetryGateway->method('isFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('getFrontendTelemetrySampleRate')->willReturn(0.2); $telemetryGateway->method('getFrontendTelemetryAllowedEvents')->willReturn(['warn_once']); $telemetryGateway->method('setFrontendTelemetryEnabled')->willReturn(true); $telemetryGateway->method('setFrontendTelemetrySampleRate')->willReturn(true); $telemetryGateway->expects($this->once()) ->method('setFrontendTelemetryAllowedEvents') ->willReturn(false); $service = $this->newService(settingsFrontendTelemetryGateway: $telemetryGateway); $result = $service->updateFromAdmin($this->validPostData([ 'frontend_telemetry_allowed_events' => ['invalid_event_type'], ])); $this->assertContains('frontend_telemetry_allowed_events_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // Helpers // --------------------------------------------------------------- /** @return list */ private function extractErrorKeys(array $result): array { $errors = is_array($result['errors'] ?? null) ? $result['errors'] : []; $errorKeys = []; foreach ($errors as $error) { if (is_array($error) && isset($error['key'])) { $errorKeys[] = (string) $error['key']; } } return $errorKeys; } /** * @param array $overrides * @return array */ private function validPostData(array $overrides = []): array { $base = [ 'default_tenant_id' => '0', 'default_role_id' => '0', 'default_department_id' => '0', 'app_title' => 'Demo', 'app_locale' => 'de', 'app_theme' => 'light', 'app_theme_user' => '1', 'app_registration' => '1', 'api_token_default_ttl_days' => '90', 'api_token_max_ttl_days' => '365', 'user_inactivity_deactivate_days' => '180', 'user_inactivity_delete_days' => '365', 'session_idle_timeout_minutes' => '30', 'session_absolute_timeout_hours' => '8', 'api_cors_allowed_origins' => '', 'system_audit_enabled' => '1', 'system_audit_retention_days' => '365', 'frontend_telemetry_enabled' => '1', 'frontend_telemetry_sample_rate' => '0.2', 'frontend_telemetry_allowed_events' => ['warn_once'], 'app_primary_color' => '#2FA4A4', 'smtp_host' => '', 'smtp_port' => '', 'smtp_user' => '', 'smtp_password' => '', 'smtp_secure' => '', 'smtp_from' => '', 'smtp_from_name' => '', 'microsoft_shared_client_id' => '', 'microsoft_shared_client_secret' => '', 'microsoft_authority' => 'https://login.microsoftonline.com/common/v2.0', ]; return array_replace($base, $overrides); } private function newService( ?SettingsMicrosoftGateway $settingsMicrosoftGateway = null, ?SettingsSystemAuditGateway $settingsSystemAuditGateway = null, ?SettingsFrontendTelemetryGateway $settingsFrontendTelemetryGateway = null ): AdminSettingsService { $settingsDefaultsGateway = $this->createConfiguredMock(SettingsDefaultsGateway::class, [ 'getDefaultTenantId' => null, 'getDefaultRoleId' => null, 'getDefaultDepartmentId' => null, 'setDefaultTenantId' => true, 'setDefaultRoleId' => true, 'setDefaultDepartmentId' => true, ]); $settingsAppGateway = $this->createConfiguredMock(SettingsAppGateway::class, [ 'getAppLocale' => 'de', 'getAppTheme' => 'light', 'isUserThemeAllowed' => true, 'isRegistrationEnabled' => true, 'getAppPrimaryColor' => '#2FA4A4', 'setAppTitle' => true, 'setAppLocale' => true, 'setAppTheme' => true, 'setUserThemeAllowed' => true, 'setRegistrationEnabled' => true, 'setAppPrimaryColor' => true, ]); $settingsApiPolicyGateway = $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ 'getApiTokenDefaultTtlDays' => 90, 'getApiTokenMaxTtlDays' => 365, 'getApiCorsAllowedOriginsText' => '', 'setApiTokenDefaultTtlDays' => true, 'setApiTokenMaxTtlDays' => true, 'setApiCorsAllowedOrigins' => true, ]); $settingsUserLifecycleGateway = $this->createConfiguredMock(SettingsUserLifecycleGateway::class, [ 'getUserInactivityDeactivateDays' => 180, 'getUserInactivityDeleteDays' => 365, 'setUserInactivityDeactivateDays' => true, 'setUserInactivityDeleteDays' => true, ]); $settingsSessionGateway = $this->createConfiguredMock(SettingsSessionGateway::class, [ 'getSessionIdleTimeoutMinutes' => 30, 'getSessionAbsoluteTimeoutHours' => 8, 'setSessionIdleTimeoutMinutes' => true, 'setSessionAbsoluteTimeoutHours' => true, ]); $settingsSystemAuditGateway = $settingsSystemAuditGateway ?? $this->createConfiguredMock(SettingsSystemAuditGateway::class, [ 'isSystemAuditEnabled' => true, 'getSystemAuditRetentionDays' => 365, 'setSystemAuditEnabled' => true, 'setSystemAuditRetentionDays' => true, ]); $settingsFrontendTelemetryGateway = $settingsFrontendTelemetryGateway ?? $this->createConfiguredMock(SettingsFrontendTelemetryGateway::class, [ 'isFrontendTelemetryEnabled' => true, 'getFrontendTelemetrySampleRate' => 0.2, 'getFrontendTelemetryAllowedEvents' => ['warn_once'], 'setFrontendTelemetryEnabled' => true, 'setFrontendTelemetrySampleRate' => true, 'setFrontendTelemetryAllowedEvents' => true, ]); $settingsMicrosoftGateway = $settingsMicrosoftGateway ?? $this->createConfiguredMock(SettingsMicrosoftGateway::class, [ 'getMicrosoftAuthority' => 'https://login.microsoftonline.com/common/v2.0', 'setMicrosoftSharedClientId' => true, 'setMicrosoftSharedClientSecret' => true, 'setMicrosoftAuthority' => true, ]); $settingsSmtpGateway = $this->createConfiguredMock(SettingsSmtpGateway::class, [ 'getSmtpSecure' => '', 'setSmtpHost' => true, 'setSmtpPort' => true, 'setSmtpUser' => true, 'setSmtpPassword' => true, 'setSmtpSecure' => true, 'setSmtpFrom' => true, 'setSmtpFromName' => true, ]); $loginPersistenceGateway = $this->createConfiguredMock(SettingsLoginPersistenceGateway::class, [ 'isMicrosoftAutoRememberDefault' => false, 'getRememberTokenLifetimeDays' => 30, 'setMicrosoftAutoRememberDefault' => true, 'setRememberTokenLifetimeDays' => true, ]); $settingsMetadataGateway = $this->createMock(SettingsMetadataGateway::class); $settingsMetadataGateway->method('getValue')->willReturn(null); $settingsMetadataGateway->method('get')->willReturnCallback( static fn (string $key): array => ['key' => $key, 'description' => 'setting.default'] ); $settingCacheService = $this->createConfiguredMock(SettingCacheService::class, [ 'update' => true, ]); $tenantService = $this->createConfiguredMock(TenantService::class, [ 'list' => [], ]); $roleService = $this->createConfiguredMock(RoleService::class, [ 'listActive' => [], ]); $departmentService = $this->createConfiguredMock(DepartmentService::class, [ 'list' => [], ]); $rememberTokenRepository = $this->createConfiguredMock(RememberTokenRepository::class, [ 'countActive' => 0, ]); $apiTokenRepository = $this->createConfiguredMock(ApiTokenRepository::class, [ 'countActive' => 0, ]); $systemAuditService = $this->createConfiguredMock(SystemAuditService::class, [ 'record' => null, ]); return new AdminSettingsService( $settingsDefaultsGateway, $settingsAppGateway, $settingsApiPolicyGateway, $settingsUserLifecycleGateway, $settingsSessionGateway, $settingsSystemAuditGateway, $settingsFrontendTelemetryGateway, $settingsMicrosoftGateway, $settingsSmtpGateway, $loginPersistenceGateway, $settingsMetadataGateway, $settingCacheService, $tenantService, $roleService, $departmentService, $rememberTokenRepository, $apiTokenRepository, $systemAuditService ); } }