1
0
Files
breadcrumb-the-shire/tests/Service/Settings/AdminSettingsServiceApiLifecycleTest.php
fs 149d4515de refactor(settings): remove global appearance settings — tenant is sole source
Removes the global app_theme, app_theme_user and app_primary_color settings
from the admin/settings area and the underlying service/cache/API/i18n/docs
layers. The tenant columns (tenants.primary_color, default_theme,
allow_user_theme) become the single source of truth for appearance.
Branding helpers are tenant-only and fall back to a hardcoded 'light' theme
with no brand color when no tenant context is available (login, error,
pre-session pages). The APP_THEME env var is removed.

Scope:
- UI: Appearance tab gone from /admin/settings; tenant edit form drops the
  global-fallback color preview.
- Service: SettingsAppGateway no longer exposes setting-backed accessors
  for theme/user-theme/primary-color. Theme catalog utilities (listThemes,
  isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used
  across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes
  'light' with a catalog fallback (no global/env lookup).
- AdminSettingsService: buildPageData, sanitization, audit snapshot, apply
  step and cache payload are all stripped of the three keys. Dead helper
  applyAppPrimaryColor + normalizePrimaryColor removed.
- Cache: SettingCacheService HOT_PATH_KEYS drops the three keys.
- API: /settings (GET/PUT) and /settings/public (GET) no longer expose
  appearance fields; OpenAPI schemas pruned accordingly.
- Audit redaction list shortened.
- DB: db/init/init.sql seed rows removed. No migration for existing
  installs — legacy rows stay inert.
- i18n + docs: setting.app_theme, setting.app_theme_user,
  setting.app_primary_color removed from de+en locales; reference and
  explanation docs updated.
- Tests: covering tests for the removed methods pruned; ThemeResolutionTest
  rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in
  UserRegistrar.php cleaned up to keep QG-006 green.

Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored).
Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions).
Reviews: code, security, acceptance all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00

481 lines
21 KiB
PHP

<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Repository\Auth\ApiTokenRepository;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Audit\AuditRecorderInterface;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Settings\AdminSettingsService;
use MintyPHP\Service\Settings\SettingCacheService;
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
use MintyPHP\Service\Settings\SettingsAppGateway;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
use MintyPHP\Service\Settings\SettingsSessionGateway;
use MintyPHP\Service\Settings\SettingsSmtpGateway;
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
use MintyPHP\Service\Tenant\TenantService;
use PHPUnit\Framework\TestCase;
class AdminSettingsServiceApiLifecycleTest extends TestCase
{
// ---------------------------------------------------------------
// API token max TTL
// ---------------------------------------------------------------
public function testUpdateFromAdminPersistsValidApiTokenMaxTtl(): 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')
->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<string> */
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<string, mixed> $overrides
* @return array<string, mixed>
*/
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
);
}
}