createMock(SettingsApiPolicyGateway::class); $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); $apiPolicyGateway->expects($this->once()) ->method('setApiTokenMaxTtlDays') ->with(180) ->willReturn(true); $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); $result = $service->updateFromAdmin($this->validPostData([ 'api_token_max_ttl_days' => '180', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidApiTokenMaxTtl(): void { $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); $apiPolicyGateway->expects($this->once()) ->method('setApiTokenMaxTtlDays') ->willReturn(false); $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); $result = $service->updateFromAdmin($this->validPostData([ 'api_token_max_ttl_days' => '99999', ])); $this->assertContains('api_token_max_ttl_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // API token default TTL // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidApiTokenDefaultTtl(): void { $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); $apiPolicyGateway->expects($this->once()) ->method('setApiTokenDefaultTtlDays') ->with(60) ->willReturn(true); $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); $result = $service->updateFromAdmin($this->validPostData([ 'api_token_default_ttl_days' => '60', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidApiTokenDefaultTtl(): void { $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); $apiPolicyGateway->expects($this->once()) ->method('setApiTokenDefaultTtlDays') ->willReturn(false); $apiPolicyGateway->method('setApiCorsAllowedOrigins')->willReturn(true); $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); $result = $service->updateFromAdmin($this->validPostData([ 'api_token_default_ttl_days' => '9999', ])); $this->assertContains('api_token_default_ttl_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // CORS allowed origins // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidCorsOrigins(): void { $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); $apiPolicyGateway->expects($this->once()) ->method('setApiCorsAllowedOrigins') ->with('https://example.com') ->willReturn(true); $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); $result = $service->updateFromAdmin($this->validPostData([ 'api_cors_allowed_origins' => 'https://example.com', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidCorsOrigins(): void { $apiPolicyGateway = $this->createMock(SettingsApiPolicyGateway::class); $apiPolicyGateway->method('getApiTokenDefaultTtlDays')->willReturn(90); $apiPolicyGateway->method('getApiTokenMaxTtlDays')->willReturn(365); $apiPolicyGateway->method('getApiCorsAllowedOriginsText')->willReturn(''); $apiPolicyGateway->method('setApiTokenMaxTtlDays')->willReturn(true); $apiPolicyGateway->method('setApiTokenDefaultTtlDays')->willReturn(true); $apiPolicyGateway->expects($this->once()) ->method('setApiCorsAllowedOrigins') ->willReturn(false); $service = $this->newService(settingsApiPolicyGateway: $apiPolicyGateway); $result = $service->updateFromAdmin($this->validPostData([ 'api_cors_allowed_origins' => 'not-a-valid-origin', ])); $this->assertContains('api_cors_allowed_origins_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // User inactivity deactivate days // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidDeactivateDays(): void { $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeactivateDays') ->with(90) ->willReturn(true); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeleteDays') ->with(365) ->willReturn(true); $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); $result = $service->updateFromAdmin($this->validPostData([ 'user_inactivity_deactivate_days' => '90', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidDeactivateDays(): void { $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeactivateDays') ->willReturn(false); $lifecycleGateway->method('setUserInactivityDeleteDays')->willReturn(true); $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); $result = $service->updateFromAdmin($this->validPostData([ 'user_inactivity_deactivate_days' => '99999', ])); $this->assertContains('user_inactivity_deactivate_days_invalid', $this->extractErrorKeys($result)); } // --------------------------------------------------------------- // User inactivity delete days // --------------------------------------------------------------- public function testUpdateFromAdminPersistsValidDeleteDays(): void { $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeactivateDays') ->with(180) ->willReturn(true); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeleteDays') ->with(730) ->willReturn(true); $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); $result = $service->updateFromAdmin($this->validPostData([ 'user_inactivity_delete_days' => '730', ])); $this->assertSame([], $result['errors'] ?? []); } public function testUpdateFromAdminReturnsErrorForInvalidDeleteDays(): void { $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(180); $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(365); $lifecycleGateway->method('setUserInactivityDeactivateDays')->willReturn(true); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeleteDays') ->willReturn(false); $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); $result = $service->updateFromAdmin($this->validPostData([ 'user_inactivity_delete_days' => '99999', ])); $this->assertContains('user_inactivity_delete_days_invalid', $this->extractErrorKeys($result)); } public function testUpdateFromAdminReturnsErrorWhenDeleteDaysSetWithDeactivateDisabled(): void { $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(0); $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(0); $lifecycleGateway->method('setUserInactivityDeactivateDays')->willReturn(true); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeleteDays') ->with(0); $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); $result = $service->updateFromAdmin($this->validPostData([ 'user_inactivity_deactivate_days' => '0', 'user_inactivity_delete_days' => '365', ])); $this->assertContains('user_inactivity_delete_requires_deactivate', $this->extractErrorKeys($result)); } public function testUpdateFromAdminForcesDeleteDaysToZeroWhenDeactivateDisabled(): void { $lifecycleGateway = $this->createMock(SettingsUserLifecycleGateway::class); $lifecycleGateway->method('getUserInactivityDeactivateDays')->willReturn(0); $lifecycleGateway->method('getUserInactivityDeleteDays')->willReturn(0); $lifecycleGateway->method('setUserInactivityDeactivateDays')->willReturn(true); $lifecycleGateway->expects($this->once()) ->method('setUserInactivityDeleteDays') ->with(0); $service = $this->newService(settingsUserLifecycleGateway: $lifecycleGateway); $service->updateFromAdmin($this->validPostData([ 'user_inactivity_deactivate_days' => '0', 'user_inactivity_delete_days' => '500', ])); } // --------------------------------------------------------------- // 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_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'], '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( ?SettingsApiPolicyGateway $settingsApiPolicyGateway = null, ?SettingsUserLifecycleGateway $settingsUserLifecycleGateway = 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', 'isRegistrationEnabled' => true, 'setAppTitle' => true, 'setAppLocale' => true, 'setRegistrationEnabled' => true, ]); $settingsApiPolicyGateway = $settingsApiPolicyGateway ?? $this->createConfiguredMock(SettingsApiPolicyGateway::class, [ 'getApiTokenDefaultTtlDays' => 90, 'getApiTokenMaxTtlDays' => 365, 'getApiCorsAllowedOriginsText' => '', 'setApiTokenDefaultTtlDays' => true, 'setApiTokenMaxTtlDays' => true, 'setApiCorsAllowedOrigins' => true, ]); $settingsUserLifecycleGateway = $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 = $this->createConfiguredMock(SettingsSystemAuditGateway::class, [ 'isSystemAuditEnabled' => true, 'getSystemAuditRetentionDays' => 365, 'setSystemAuditEnabled' => true, 'setSystemAuditRetentionDays' => true, ]); $settingsFrontendTelemetryGateway = $this->createConfiguredMock(SettingsFrontendTelemetryGateway::class, [ 'isFrontendTelemetryEnabled' => true, 'getFrontendTelemetrySampleRate' => 0.2, 'getFrontendTelemetryAllowedEvents' => ['warn_once'], 'setFrontendTelemetryEnabled' => true, 'setFrontendTelemetrySampleRate' => true, 'setFrontendTelemetryAllowedEvents' => true, ]); $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(AuditRecorderInterface::class, [ 'record' => null, ]); return new AdminSettingsService( $settingsDefaultsGateway, $settingsAppGateway, $settingsApiPolicyGateway, $settingsUserLifecycleGateway, $settingsSessionGateway, $settingsSystemAuditGateway, $settingsFrontendTelemetryGateway, $settingsMicrosoftGateway, $settingsSmtpGateway, $loginPersistenceGateway, $settingsMetadataGateway, $settingCacheService, $tenantService, $roleService, $departmentService, $rememberTokenRepository, $apiTokenRepository, $systemAuditService ); } }