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>
374 lines
15 KiB
PHP
374 lines
15 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 AdminSettingsServiceAppTest extends TestCase
|
|
{
|
|
// ---------------------------------------------------------------
|
|
// buildPageData: sorted tenants/roles/departments
|
|
// ---------------------------------------------------------------
|
|
|
|
public function testBuildPageDataReturnsSortedTenantsByDescription(): void
|
|
{
|
|
$tenantService = $this->createConfiguredMock(TenantService::class, [
|
|
'list' => [
|
|
['id' => 2, 'description' => 'Zeta Corp'],
|
|
['id' => 1, 'description' => 'Alpha Inc'],
|
|
['id' => 3, 'description' => 'Mu Ltd'],
|
|
],
|
|
]);
|
|
$roleService = $this->createConfiguredMock(RoleService::class, [
|
|
'listActive' => [
|
|
['id' => 2, 'description' => 'Viewer'],
|
|
['id' => 1, 'description' => 'Admin'],
|
|
],
|
|
]);
|
|
$departmentService = $this->createConfiguredMock(DepartmentService::class, [
|
|
'list' => [
|
|
['id' => 3, 'description' => 'Sales'],
|
|
['id' => 1, 'description' => 'Engineering'],
|
|
['id' => 2, 'description' => 'Marketing'],
|
|
],
|
|
]);
|
|
|
|
$service = $this->newService(
|
|
tenantService: $tenantService,
|
|
roleService: $roleService,
|
|
departmentService: $departmentService
|
|
);
|
|
$data = $service->buildPageData();
|
|
|
|
$tenantDescs = array_column($data['tenants'], 'description');
|
|
$this->assertSame(['Alpha Inc', 'Mu Ltd', 'Zeta Corp'], $tenantDescs);
|
|
|
|
$roleDescs = array_column($data['roles'], 'description');
|
|
$this->assertSame(['Admin', 'Viewer'], $roleDescs);
|
|
|
|
$deptDescs = array_column($data['departments'], 'description');
|
|
$this->assertSame(['Engineering', 'Marketing', 'Sales'], $deptDescs);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// buildPageData: active token counts
|
|
// ---------------------------------------------------------------
|
|
|
|
public function testBuildPageDataIncludesActiveTokenCounts(): void
|
|
{
|
|
$rememberTokenRepository = $this->createConfiguredMock(RememberTokenRepository::class, [
|
|
'countActive' => 42,
|
|
]);
|
|
$apiTokenRepository = $this->createConfiguredMock(ApiTokenRepository::class, [
|
|
'countActive' => 7,
|
|
]);
|
|
|
|
$service = $this->newService(
|
|
rememberTokenRepository: $rememberTokenRepository,
|
|
apiTokenRepository: $apiTokenRepository
|
|
);
|
|
$data = $service->buildPageData();
|
|
|
|
$this->assertSame(42, $data['active_login_tokens']);
|
|
$this->assertSame(7, $data['active_api_tokens']);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// buildPageData: values array contains expected keys
|
|
// ---------------------------------------------------------------
|
|
|
|
public function testBuildPageDataValuesContainExpectedKeys(): void
|
|
{
|
|
$service = $this->newService();
|
|
$data = $service->buildPageData();
|
|
|
|
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
|
|
|
|
$expectedKeys = [
|
|
'default_tenant_id',
|
|
'default_role_id',
|
|
'default_department_id',
|
|
'app_title',
|
|
'app_locale',
|
|
'app_registration',
|
|
'api_token_default_ttl_days',
|
|
'api_token_max_ttl_days',
|
|
'user_inactivity_deactivate_days',
|
|
'user_inactivity_delete_days',
|
|
'session_idle_timeout_minutes',
|
|
'session_absolute_timeout_hours',
|
|
'microsoft_auto_remember_default',
|
|
'remember_token_lifetime_days',
|
|
'api_cors_allowed_origins',
|
|
'system_audit_enabled',
|
|
'system_audit_retention_days',
|
|
'frontend_telemetry_enabled',
|
|
'frontend_telemetry_sample_rate',
|
|
'frontend_telemetry_allowed_events',
|
|
'microsoft_shared_client_id',
|
|
'microsoft_authority',
|
|
'smtp_host',
|
|
'smtp_port',
|
|
'smtp_user',
|
|
'smtp_secure',
|
|
'smtp_from',
|
|
'smtp_from_name',
|
|
];
|
|
|
|
foreach ($expectedKeys as $key) {
|
|
$this->assertArrayHasKey($key, $values, "Expected key '$key' missing from values array");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// SMTP settings: pass-through (gateway setters called)
|
|
// ---------------------------------------------------------------
|
|
|
|
public function testUpdateFromAdminCallsSmtpGatewaySetters(): void
|
|
{
|
|
$smtpGateway = $this->createMock(SettingsSmtpGateway::class);
|
|
$smtpGateway->method('getSmtpSecure')->willReturn('');
|
|
$smtpGateway->expects($this->once())->method('setSmtpHost')->with('mail.example.com');
|
|
$smtpGateway->expects($this->once())->method('setSmtpPort')->with(587);
|
|
$smtpGateway->expects($this->once())->method('setSmtpUser')->with('user@example.com');
|
|
$smtpGateway->expects($this->once())->method('setSmtpPassword')->with('secret');
|
|
$smtpGateway->expects($this->once())->method('setSmtpSecure')->with('tls');
|
|
$smtpGateway->expects($this->once())->method('setSmtpFrom')->with('noreply@example.com');
|
|
$smtpGateway->expects($this->once())->method('setSmtpFromName')->with('CoreCore');
|
|
|
|
$service = $this->newService(settingsSmtpGateway: $smtpGateway);
|
|
$service->updateFromAdmin($this->validPostData([
|
|
'smtp_host' => 'mail.example.com',
|
|
'smtp_port' => '587',
|
|
'smtp_user' => 'user@example.com',
|
|
'smtp_password' => 'secret',
|
|
'smtp_secure' => 'tls',
|
|
'smtp_from' => 'noreply@example.com',
|
|
'smtp_from_name' => 'CoreCore',
|
|
]));
|
|
}
|
|
|
|
public function testUpdateFromAdminSkipsSmtpPasswordWhenEmpty(): void
|
|
{
|
|
$smtpGateway = $this->createMock(SettingsSmtpGateway::class);
|
|
$smtpGateway->method('getSmtpSecure')->willReturn('');
|
|
$smtpGateway->method('setSmtpHost');
|
|
$smtpGateway->method('setSmtpPort');
|
|
$smtpGateway->method('setSmtpUser');
|
|
$smtpGateway->expects($this->never())->method('setSmtpPassword');
|
|
$smtpGateway->method('setSmtpSecure');
|
|
$smtpGateway->method('setSmtpFrom');
|
|
$smtpGateway->method('setSmtpFromName');
|
|
|
|
$service = $this->newService(settingsSmtpGateway: $smtpGateway);
|
|
$service->updateFromAdmin($this->validPostData([
|
|
'smtp_password' => '',
|
|
]));
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------
|
|
|
|
/**
|
|
* @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(
|
|
?SettingsAppGateway $settingsAppGateway = null,
|
|
?SettingsSmtpGateway $settingsSmtpGateway = null,
|
|
?TenantService $tenantService = null,
|
|
?RoleService $roleService = null,
|
|
?DepartmentService $departmentService = null,
|
|
?RememberTokenRepository $rememberTokenRepository = null,
|
|
?ApiTokenRepository $apiTokenRepository = null
|
|
): AdminSettingsService {
|
|
$settingsDefaultsGateway = $this->createConfiguredMock(SettingsDefaultsGateway::class, [
|
|
'getDefaultTenantId' => null,
|
|
'getDefaultRoleId' => null,
|
|
'getDefaultDepartmentId' => null,
|
|
'setDefaultTenantId' => true,
|
|
'setDefaultRoleId' => true,
|
|
'setDefaultDepartmentId' => true,
|
|
]);
|
|
|
|
$settingsAppGateway = $settingsAppGateway ?? $this->createConfiguredMock(SettingsAppGateway::class, [
|
|
'getAppLocale' => 'de',
|
|
'isRegistrationEnabled' => true,
|
|
'setAppTitle' => true,
|
|
'setAppLocale' => true,
|
|
'setRegistrationEnabled' => 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 = $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 = $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 = $tenantService ?? $this->createConfiguredMock(TenantService::class, [
|
|
'list' => [],
|
|
]);
|
|
$roleService = $roleService ?? $this->createConfiguredMock(RoleService::class, [
|
|
'listActive' => [],
|
|
]);
|
|
$departmentService = $departmentService ?? $this->createConfiguredMock(DepartmentService::class, [
|
|
'list' => [],
|
|
]);
|
|
|
|
$rememberTokenRepository = $rememberTokenRepository ?? $this->createConfiguredMock(RememberTokenRepository::class, [
|
|
'countActive' => 0,
|
|
]);
|
|
$apiTokenRepository = $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
|
|
);
|
|
}
|
|
}
|