From 4e2d7e70afae5bd0c2ea88190c5fbc67c47281d8 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 16 Feb 2026 04:16:37 +0100 Subject: [PATCH] feat: workspace settings slices (backup, drift, operations) --- .../Pages/Settings/WorkspaceSettings.php | 457 ++++++++++++++++-- app/Jobs/ApplyBackupScheduleRetentionJob.php | 25 +- app/Jobs/PruneOldOperationRunsJob.php | 53 +- app/Models/Finding.php | 2 + app/Models/TenantSetting.php | 4 + app/Models/WorkspaceSetting.php | 4 + app/Services/Drift/DriftFindingGenerator.php | 69 ++- .../Badges/Domains/FindingSeverityBadge.php | 1 + app/Support/Settings/SettingsRegistry.php | 113 ++++- .../BackupScheduleLifecycleTest.php | 137 ++++++ .../DriftPolicySnapshotDriftDetectionTest.php | 72 ++- .../PruneOldOperationRunsScheduleTest.php | 58 +++ .../WorkspaceSettingsManageTest.php | 273 ++++++++++- .../WorkspaceSettingsViewOnlyTest.php | 20 +- tests/Unit/Badges/FindingBadgesTest.php | 4 + 15 files changed, 1202 insertions(+), 90 deletions(-) diff --git a/app/Filament/Pages/Settings/WorkspaceSettings.php b/app/Filament/Pages/Settings/WorkspaceSettings.php index 4402c73..3395883 100644 --- a/app/Filament/Pages/Settings/WorkspaceSettings.php +++ b/app/Filament/Pages/Settings/WorkspaceSettings.php @@ -10,17 +10,21 @@ use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsWriter; use App\Support\Auth\Capabilities; +use App\Support\Settings\SettingDefinition; +use App\Support\Settings\SettingsRegistry; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Notifications\Notification; use Filament\Pages\Page; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -40,6 +44,17 @@ class WorkspaceSettings extends Page protected static ?int $navigationSort = 20; + /** + * @var array + */ + private const SETTING_FIELDS = [ + 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], + 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], + 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], + 'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'], + 'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'], + ]; + public Workspace $workspace; /** @@ -47,6 +62,16 @@ class WorkspaceSettings extends Page */ public array $data = []; + /** + * @var array + */ + public array $workspaceOverrides = []; + + /** + * @var array + */ + public array $resolvedSettings = []; + /** * @return array */ @@ -62,24 +87,13 @@ protected function getHeaderActions(): array ->tooltip(fn (): ?string => $this->currentUserCanManage() ? null : 'You do not have permission to manage workspace settings.'), - Action::make('reset') - ->label('Reset to default') - ->color('danger') - ->requiresConfirmation() - ->action(function (): void { - $this->resetSetting(); - }) - ->disabled(fn (): bool => ! $this->currentUserCanManage()) - ->tooltip(fn (): ?string => $this->currentUserCanManage() - ? null - : 'You do not have permission to manage workspace settings.'), ]; } public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) - ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide save and reset controls for the settings form.') + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.') ->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.') @@ -125,12 +139,59 @@ public function content(Schema $schema): Schema ->schema([ TextInput::make('backup_retention_keep_last_default') ->label('Default retention keep-last') + ->placeholder('Unset (uses default)') ->numeric() ->integer() ->minValue(1) - ->required() + ->maxValue(365) ->disabled(fn (): bool => ! $this->currentUserCanManage()) - ->helperText('Fallback value for backup schedule retention when retention_keep_last is empty.'), + ->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default')) + ->hintAction($this->makeResetAction('backup_retention_keep_last_default')), + TextInput::make('backup_retention_min_floor') + ->label('Minimum retention floor') + ->placeholder('Unset (uses default)') + ->numeric() + ->integer() + ->minValue(1) + ->maxValue(365) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor')) + ->hintAction($this->makeResetAction('backup_retention_min_floor')), + ]), + Section::make('Drift settings') + ->description('Map finding types to severities as JSON.') + ->schema([ + Textarea::make('drift_severity_mapping') + ->label('Severity mapping (JSON object)') + ->rows(8) + ->placeholder("{\n \"drift\": \"critical\"\n}") + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping')) + ->hintAction($this->makeResetAction('drift_severity_mapping')), + ]), + Section::make('Operations settings') + ->description('Workspace controls for operations retention and thresholds.') + ->schema([ + TextInput::make('operations_operation_run_retention_days') + ->label('Operation run retention (days)') + ->placeholder('Unset (uses default)') + ->numeric() + ->integer() + ->minValue(7) + ->maxValue(3650) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days')) + ->hintAction($this->makeResetAction('operations_operation_run_retention_days')), + TextInput::make('operations_stuck_run_threshold_minutes') + ->label('Stuck run threshold (minutes)') + ->placeholder('Unset (uses default)') + ->numeric() + ->integer() + ->minValue(0) + ->maxValue(10080) + ->disabled(fn (): bool => ! $this->currentUserCanManage()) + ->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes')) + ->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')), ]), ]); } @@ -145,35 +206,60 @@ public function save(): void $this->authorizeWorkspaceManage($user); - try { - app(SettingsWriter::class)->updateWorkspaceSetting( - actor: $user, - workspace: $this->workspace, - domain: 'backup', - key: 'retention_keep_last_default', - value: $this->data['backup_retention_keep_last_default'] ?? null, - ); - } catch (ValidationException $exception) { - $errors = $exception->errors(); + [$normalizedValues, $validationErrors] = $this->normalizedInputValues(); - if (isset($errors['value'])) { - throw ValidationException::withMessages([ - 'data.backup_retention_keep_last_default' => $errors['value'], - ]); + if ($validationErrors !== []) { + throw ValidationException::withMessages($validationErrors); + } + + $writer = app(SettingsWriter::class); + $changedSettingsCount = 0; + + foreach (self::SETTING_FIELDS as $field => $setting) { + $incomingValue = $normalizedValues[$field] ?? null; + $currentOverride = $this->workspaceOverrideForField($field); + + if ($incomingValue === null) { + if ($currentOverride === null) { + continue; + } + + $writer->resetWorkspaceSetting( + actor: $user, + workspace: $this->workspace, + domain: $setting['domain'], + key: $setting['key'], + ); + + $changedSettingsCount++; + + continue; } - throw $exception; + if ($this->valuesEqual($incomingValue, $currentOverride)) { + continue; + } + + $writer->updateWorkspaceSetting( + actor: $user, + workspace: $this->workspace, + domain: $setting['domain'], + key: $setting['key'], + value: $incomingValue, + ); + + $changedSettingsCount++; } $this->loadFormState(); Notification::make() - ->title('Workspace settings saved') + ->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') ->success() ->send(); } - public function resetSetting(): void + public function resetSetting(string $field): void { $user = auth()->user(); @@ -183,11 +269,22 @@ public function resetSetting(): void $this->authorizeWorkspaceManage($user); + $setting = $this->settingForField($field); + + if ($this->workspaceOverrideForField($field) === null) { + Notification::make() + ->title('Setting already uses default') + ->success() + ->send(); + + return; + } + app(SettingsWriter::class)->resetWorkspaceSetting( actor: $user, workspace: $this->workspace, - domain: 'backup', - key: 'retention_keep_last_default', + domain: $setting['domain'], + key: $setting['key'], ); $this->loadFormState(); @@ -200,15 +297,295 @@ public function resetSetting(): void private function loadFormState(): void { - $resolvedValue = app(SettingsResolver::class)->resolveValue( - workspace: $this->workspace, - domain: 'backup', - key: 'retention_keep_last_default', + $resolver = app(SettingsResolver::class); + + $data = []; + $workspaceOverrides = []; + $resolvedSettings = []; + + foreach (self::SETTING_FIELDS as $field => $setting) { + $resolved = $resolver->resolveDetailed( + workspace: $this->workspace, + domain: $setting['domain'], + key: $setting['key'], + ); + + $workspaceValue = $resolved['workspace_value']; + + $workspaceOverrides[$field] = $workspaceValue; + $resolvedSettings[$field] = [ + 'source' => $resolved['source'], + 'value' => $resolved['value'], + 'system_default' => $resolved['system_default'], + ]; + + $data[$field] = $workspaceValue === null + ? null + : $this->formatValueForInput($field, $workspaceValue); + } + + $this->data = $data; + $this->workspaceOverrides = $workspaceOverrides; + $this->resolvedSettings = $resolvedSettings; + } + + private function makeResetAction(string $field): Action + { + return Action::make('reset_'.$field) + ->label('Reset') + ->color('danger') + ->requiresConfirmation() + ->action(function () use ($field): void { + $this->resetSetting($field); + }) + ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field)) + ->tooltip(function () use ($field): ?string { + if (! $this->currentUserCanManage()) { + return 'You do not have permission to manage workspace settings.'; + } + + if (! $this->hasWorkspaceOverride($field)) { + return 'No workspace override to reset.'; + } + + return null; + }); + } + + private function helperTextFor(string $field): string + { + $resolved = $this->resolvedSettings[$field] ?? null; + + if (! is_array($resolved)) { + return ''; + } + + $effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null); + + if (! $this->hasWorkspaceOverride($field)) { + return sprintf( + 'Unset. Effective value: %s (%s).', + $effectiveValue, + $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')), + ); + } + + return sprintf('Effective value: %s.', $effectiveValue); + } + + /** + * @return array{0: array, 1: array>} + */ + private function normalizedInputValues(): array + { + $normalizedValues = []; + $validationErrors = []; + + foreach (self::SETTING_FIELDS as $field => $_setting) { + try { + $normalizedValues[$field] = $this->normalizeFieldInput( + field: $field, + value: $this->data[$field] ?? null, + ); + } catch (ValidationException $exception) { + $messages = []; + + foreach ($exception->errors() as $errorMessages) { + foreach ((array) $errorMessages as $message) { + $messages[] = (string) $message; + } + } + + $validationErrors['data.'.$field] = $messages !== [] + ? $messages + : ['Invalid value.']; + } + } + + return [$normalizedValues, $validationErrors]; + } + + private function normalizeFieldInput(string $field, mixed $value): mixed + { + $setting = $this->settingForField($field); + + if ($value === null) { + return null; + } + + if (is_string($value) && trim($value) === '') { + return null; + } + + if ($setting['type'] === 'json') { + $value = $this->normalizeJsonInput($value); + } + + $definition = $this->settingDefinition($field); + + $validator = Validator::make( + data: ['value' => $value], + rules: ['value' => $definition->rules], ); - $this->data = [ - 'backup_retention_keep_last_default' => is_numeric($resolvedValue) ? (int) $resolvedValue : 30, - ]; + if ($validator->fails()) { + throw ValidationException::withMessages($validator->errors()->toArray()); + } + + return $definition->normalize($validator->validated()['value']); + } + + private function normalizeJsonInput(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (! is_string($value)) { + throw ValidationException::withMessages([ + 'value' => ['The value must be valid JSON.'], + ]); + } + + $decoded = json_decode($value, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw ValidationException::withMessages([ + 'value' => ['The value must be valid JSON.'], + ]); + } + + if (! is_array($decoded)) { + throw ValidationException::withMessages([ + 'value' => ['The value must be a JSON object.'], + ]); + } + + return $decoded; + } + + private function valuesEqual(mixed $left, mixed $right): bool + { + if ($left === null || $right === null) { + return $left === $right; + } + + if (is_array($left) && is_array($right)) { + return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right); + } + + if (is_numeric($left) && is_numeric($right)) { + return (int) $left === (int) $right; + } + + return $left === $right; + } + + private function encodeCanonicalArray(array $value): string + { + $encoded = json_encode($this->sortNestedArray($value)); + + return is_string($encoded) ? $encoded : ''; + } + + /** + * @param array $value + * @return array + */ + private function sortNestedArray(array $value): array + { + foreach ($value as $key => $item) { + if (! is_array($item)) { + continue; + } + + $value[$key] = $this->sortNestedArray($item); + } + + ksort($value); + + return $value; + } + + private function formatValueForInput(string $field, mixed $value): mixed + { + $setting = $this->settingForField($field); + + if ($setting['type'] === 'json') { + if (! is_array($value)) { + return null; + } + + $encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return is_string($encoded) ? $encoded : null; + } + + return is_numeric($value) ? (int) $value : null; + } + + private function formatValueForDisplay(string $field, mixed $value): string + { + $setting = $this->settingForField($field); + + if ($setting['type'] === 'json') { + if (! is_array($value) || $value === []) { + return '{}'; + } + + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES); + + return is_string($encoded) ? $encoded : '{}'; + } + + return is_numeric($value) ? (string) (int) $value : 'null'; + } + + private function sourceLabel(string $source): string + { + return match ($source) { + 'workspace_override' => 'workspace override', + 'tenant_override' => 'tenant override', + default => 'system default', + }; + } + + /** + * @return array{domain: string, key: string, type: 'int'|'json'} + */ + private function settingForField(string $field): array + { + if (! isset(self::SETTING_FIELDS[$field])) { + throw ValidationException::withMessages([ + 'data' => [sprintf('Unknown settings field: %s', $field)], + ]); + } + + return self::SETTING_FIELDS[$field]; + } + + private function settingDefinition(string $field): SettingDefinition + { + $setting = $this->settingForField($field); + + return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']); + } + + private function hasWorkspaceOverride(string $field): bool + { + return $this->workspaceOverrideForField($field) !== null; + } + + private function workspaceOverrideForField(string $field): mixed + { + $setting = $this->settingForField($field); + $resolved = app(SettingsResolver::class)->resolveDetailed( + workspace: $this->workspace, + domain: $setting['domain'], + key: $setting['key'], + ); + + return $resolved['workspace_value']; } private function currentUserCanManage(): bool diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php index ed197c9..8f0ac8c 100644 --- a/app/Jobs/ApplyBackupScheduleRetentionJob.php +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -46,17 +46,31 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol ]); $keepLast = $schedule->retention_keep_last; + $retentionFloor = 1; + + if ($schedule->tenant->workspace instanceof \App\Models\Workspace) { + $resolvedFloor = $settingsResolver->resolveValue( + workspace: $schedule->tenant->workspace, + domain: 'backup', + key: 'retention_min_floor', + tenant: $schedule->tenant, + ); + + if (is_numeric($resolvedFloor)) { + $retentionFloor = max(1, (int) $resolvedFloor); + } + } if ($keepLast === null && $schedule->tenant->workspace instanceof \App\Models\Workspace) { - $resolved = $settingsResolver->resolveValue( + $resolvedDefault = $settingsResolver->resolveValue( workspace: $schedule->tenant->workspace, domain: 'backup', key: 'retention_keep_last_default', tenant: $schedule->tenant, ); - if (is_numeric($resolved)) { - $keepLast = (int) $resolved; + if (is_numeric($resolvedDefault)) { + $keepLast = (int) $resolvedDefault; } } @@ -66,8 +80,8 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol $keepLast = (int) $keepLast; - if ($keepLast < 1) { - $keepLast = 1; + if ($keepLast < $retentionFloor) { + $keepLast = $retentionFloor; } /** @var Collection $keepBackupSetIds */ @@ -140,6 +154,7 @@ public function handle(AuditLogger $auditLogger, SettingsResolver $settingsResol context: [ 'metadata' => [ 'keep_last' => $keepLast, + 'retention_floor' => $retentionFloor, 'deleted_backup_sets' => $deletedCount, 'operation_run_id' => (int) $operationRun->getKey(), ], diff --git a/app/Jobs/PruneOldOperationRunsJob.php b/app/Jobs/PruneOldOperationRunsJob.php index 315bbe1..570696c 100644 --- a/app/Jobs/PruneOldOperationRunsJob.php +++ b/app/Jobs/PruneOldOperationRunsJob.php @@ -3,6 +3,8 @@ namespace App\Jobs; use App\Models\OperationRun; +use App\Models\Workspace; +use App\Services\Settings\SettingsResolver; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -13,19 +15,50 @@ class PruneOldOperationRunsJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** - * Create a new job instance. - */ - public function __construct( - public int $retentionDays = 90 - ) {} - /** * Execute the job. */ - public function handle(): void + public function handle(SettingsResolver $settingsResolver): void { - OperationRun::where('created_at', '<', now()->subDays($this->retentionDays)) - ->delete(); + $workspaceIds = OperationRun::query() + ->whereNotNull('workspace_id') + ->distinct() + ->orderBy('workspace_id') + ->pluck('workspace_id') + ->filter(fn ($workspaceId): bool => is_numeric($workspaceId)) + ->map(fn ($workspaceId): int => (int) $workspaceId) + ->values(); + + if ($workspaceIds->isEmpty()) { + return; + } + + $workspaces = Workspace::query() + ->whereIn('id', $workspaceIds->all()) + ->get() + ->keyBy(fn (Workspace $workspace): int => (int) $workspace->getKey()); + + foreach ($workspaceIds as $workspaceId) { + $workspace = $workspaces->get($workspaceId); + + if (! $workspace instanceof Workspace) { + continue; + } + + $resolvedRetentionDays = $settingsResolver->resolveValue( + workspace: $workspace, + domain: 'operations', + key: 'operation_run_retention_days', + ); + + $retentionDays = is_numeric($resolvedRetentionDays) + ? max(7, min(3650, (int) $resolvedRetentionDays)) + : 90; + + OperationRun::query() + ->where('workspace_id', $workspaceId) + ->where('created_at', '<', now()->subDays($retentionDays)) + ->delete(); + } } } diff --git a/app/Models/Finding.php b/app/Models/Finding.php index 7ad310d..260477a 100644 --- a/app/Models/Finding.php +++ b/app/Models/Finding.php @@ -22,6 +22,8 @@ class Finding extends Model public const string SEVERITY_HIGH = 'high'; + public const string SEVERITY_CRITICAL = 'critical'; + public const string STATUS_NEW = 'new'; public const string STATUS_ACKNOWLEDGED = 'acknowledged'; diff --git a/app/Models/TenantSetting.php b/app/Models/TenantSetting.php index ca73a29..9ee78cb 100644 --- a/app/Models/TenantSetting.php +++ b/app/Models/TenantSetting.php @@ -16,6 +16,10 @@ class TenantSetting extends Model protected $guarded = []; + protected $casts = [ + 'value' => 'array', + ]; + public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); diff --git a/app/Models/WorkspaceSetting.php b/app/Models/WorkspaceSetting.php index 931459d..9f59194 100644 --- a/app/Models/WorkspaceSetting.php +++ b/app/Models/WorkspaceSetting.php @@ -14,6 +14,10 @@ class WorkspaceSetting extends Model protected $guarded = []; + protected $casts = [ + 'value' => 'array', + ]; + public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); diff --git a/app/Services/Drift/DriftFindingGenerator.php b/app/Services/Drift/DriftFindingGenerator.php index 99f7a8d..61069c2 100644 --- a/app/Services/Drift/DriftFindingGenerator.php +++ b/app/Services/Drift/DriftFindingGenerator.php @@ -7,8 +7,10 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Models\Workspace; use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; +use App\Services\Settings\SettingsResolver; use Illuminate\Support\Arr; use RuntimeException; @@ -19,6 +21,7 @@ public function __construct( private readonly DriftEvidence $evidence, private readonly SettingsNormalizer $settingsNormalizer, private readonly ScopeTagsNormalizer $scopeTagsNormalizer, + private readonly SettingsResolver $settingsResolver, ) {} public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int @@ -38,12 +41,13 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c $policyTypes = array_values(array_filter(array_map('strval', $policyTypes))); $created = 0; + $resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT); Policy::query() ->where('tenant_id', $tenant->getKey()) ->whereIn('policy_type', $policyTypes) ->orderBy('id') - ->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$created): void { + ->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, &$created): void { foreach ($policies as $policy) { if (! $policy instanceof Policy) { continue; @@ -118,7 +122,7 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'policy', 'subject_external_id' => (string) $policy->external_id, - 'severity' => Finding::SEVERITY_MEDIUM, + 'severity' => $resolvedSeverity, 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), ]); @@ -191,7 +195,7 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'assignment', 'subject_external_id' => (string) $policy->external_id, - 'severity' => Finding::SEVERITY_MEDIUM, + 'severity' => $resolvedSeverity, 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), ]); @@ -266,7 +270,7 @@ public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $c 'current_operation_run_id' => $current->getKey(), 'subject_type' => 'scope_tag', 'subject_external_id' => (string) $policy->external_id, - 'severity' => Finding::SEVERITY_MEDIUM, + 'severity' => $resolvedSeverity, 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), ]); @@ -302,4 +306,61 @@ private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersio ->latest('captured_at') ->first(); } + + private function resolveSeverityForFindingType(Tenant $tenant, string $findingType): string + { + $workspace = $tenant->workspace; + + if (! $workspace instanceof Workspace && is_numeric($tenant->workspace_id)) { + $workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first(); + } + + if (! $workspace instanceof Workspace) { + return Finding::SEVERITY_MEDIUM; + } + + $resolved = $this->settingsResolver->resolveValue( + workspace: $workspace, + domain: 'drift', + key: 'severity_mapping', + tenant: $tenant, + ); + + if (! is_array($resolved)) { + return Finding::SEVERITY_MEDIUM; + } + + foreach ($resolved as $mappedFindingType => $mappedSeverity) { + if (! is_string($mappedFindingType) || ! is_string($mappedSeverity)) { + continue; + } + + if ($mappedFindingType !== $findingType) { + continue; + } + + $normalizedSeverity = strtolower($mappedSeverity); + + if (in_array($normalizedSeverity, $this->supportedSeverities(), true)) { + return $normalizedSeverity; + } + + break; + } + + return Finding::SEVERITY_MEDIUM; + } + + /** + * @return array + */ + private function supportedSeverities(): array + { + return [ + Finding::SEVERITY_LOW, + Finding::SEVERITY_MEDIUM, + Finding::SEVERITY_HIGH, + Finding::SEVERITY_CRITICAL, + ]; + } } diff --git a/app/Support/Badges/Domains/FindingSeverityBadge.php b/app/Support/Badges/Domains/FindingSeverityBadge.php index d168ca3..18472ff 100644 --- a/app/Support/Badges/Domains/FindingSeverityBadge.php +++ b/app/Support/Badges/Domains/FindingSeverityBadge.php @@ -17,6 +17,7 @@ public function spec(mixed $value): BadgeSpec Finding::SEVERITY_LOW => new BadgeSpec('Low', 'gray', 'heroicon-m-minus-circle'), Finding::SEVERITY_MEDIUM => new BadgeSpec('Medium', 'warning', 'heroicon-m-exclamation-triangle'), Finding::SEVERITY_HIGH => new BadgeSpec('High', 'danger', 'heroicon-m-x-circle'), + Finding::SEVERITY_CRITICAL => new BadgeSpec('Critical', 'danger', 'heroicon-m-fire'), default => BadgeSpec::unknown(), }; } diff --git a/app/Support/Settings/SettingsRegistry.php b/app/Support/Settings/SettingsRegistry.php index 4eb831e..efdade6 100644 --- a/app/Support/Settings/SettingsRegistry.php +++ b/app/Support/Settings/SettingsRegistry.php @@ -4,6 +4,8 @@ namespace App\Support\Settings; +use App\Models\Finding; + final class SettingsRegistry { /** @@ -20,7 +22,79 @@ public function __construct() key: 'retention_keep_last_default', type: 'int', systemDefault: 30, - rules: ['required', 'integer', 'min:1', 'max:3650'], + rules: ['required', 'integer', 'min:1', 'max:365'], + normalizer: static fn (mixed $value): int => (int) $value, + )); + + $this->register(new SettingDefinition( + domain: 'backup', + key: 'retention_min_floor', + type: 'int', + systemDefault: 1, + rules: ['required', 'integer', 'min:1', 'max:365'], + normalizer: static fn (mixed $value): int => (int) $value, + )); + + $this->register(new SettingDefinition( + domain: 'drift', + key: 'severity_mapping', + type: 'json', + systemDefault: [], + rules: [ + 'required', + 'array', + static function (string $attribute, mixed $value, \Closure $fail): void { + if (! is_array($value)) { + $fail('The severity mapping must be a JSON object.'); + + return; + } + + foreach ($value as $findingType => $severity) { + if (! is_string($findingType) || trim($findingType) === '') { + $fail('Each severity mapping key must be a non-empty string.'); + + return; + } + + if (! is_string($severity)) { + $fail(sprintf('Severity for "%s" must be a string.', $findingType)); + + return; + } + + $normalizedSeverity = strtolower($severity); + + if (! in_array($normalizedSeverity, self::supportedFindingSeverities(), true)) { + $fail(sprintf( + 'Severity for "%s" must be one of: %s.', + $findingType, + implode(', ', self::supportedFindingSeverities()), + )); + + return; + } + } + }, + ], + normalizer: static fn (mixed $value): array => self::normalizeSeverityMapping($value), + )); + + $this->register(new SettingDefinition( + domain: 'operations', + key: 'operation_run_retention_days', + type: 'int', + systemDefault: 90, + rules: ['required', 'integer', 'min:7', 'max:3650'], + normalizer: static fn (mixed $value): int => (int) $value, + )); + + $this->register(new SettingDefinition( + domain: 'operations', + key: 'stuck_run_threshold_minutes', + type: 'int', + systemDefault: 0, + rules: ['required', 'integer', 'min:0', 'max:10080'], normalizer: static fn (mixed $value): int => (int) $value, )); } @@ -58,4 +132,41 @@ private function cacheKey(string $domain, string $key): string { return $domain.'.'.$key; } + + /** + * @return array + */ + private static function supportedFindingSeverities(): array + { + return [ + Finding::SEVERITY_LOW, + Finding::SEVERITY_MEDIUM, + Finding::SEVERITY_HIGH, + Finding::SEVERITY_CRITICAL, + ]; + } + + /** + * @return array + */ + private static function normalizeSeverityMapping(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $normalized = []; + + foreach ($value as $findingType => $severity) { + if (! is_string($findingType) || trim($findingType) === '' || ! is_string($severity)) { + continue; + } + + $normalized[$findingType] = strtolower($severity); + } + + ksort($normalized); + + return $normalized; + } } diff --git a/tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php b/tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php index ca6e195..fef114e 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php @@ -2,8 +2,11 @@ use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule; use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; +use App\Jobs\ApplyBackupScheduleRetentionJob; use App\Models\BackupSchedule; +use App\Models\BackupSet; use App\Models\OperationRun; +use App\Models\WorkspaceSetting; use Filament\Facades\Filament; use Filament\Tables\Filters\TrashedFilter; use Illuminate\Auth\Access\AuthorizationException; @@ -204,3 +207,137 @@ function makeBackupScheduleForLifecycle(\App\Models\Tenant $tenant, array $attri expect((bool) $active->fresh()->trashed())->toBeFalse(); expect((bool) BackupSchedule::withTrashed()->findOrFail($archived->id)->trashed())->toBeTrue(); }); + +it('clamps resolved workspace retention default to workspace retention floor', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 2, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'backup', + 'key' => 'retention_min_floor', + 'value' => 4, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $schedule = makeBackupScheduleForLifecycle($tenant, [ + 'name' => 'Floor clamp default', + 'retention_keep_last' => null, + ]); + + $sets = collect(range(1, 6))->map(function (int $index) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Clamp Default '.$index, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(12 - $index), + ]); + }); + + $completedAt = now('UTC')->startOfMinute()->subMinutes(8); + + foreach ($sets as $set) { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_run', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'lifecycle-floor-default:'.$schedule->id.':'.$set->id), + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_set_id' => (int) $set->id, + ], + 'started_at' => $completedAt, + 'completed_at' => $completedAt, + ]); + + $completedAt = $completedAt->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->id); + + $keptIds = BackupSet::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereNull('deleted_at') + ->orderBy('id') + ->pluck('id') + ->all(); + + expect($keptIds)->toHaveCount(4); +}); + +it('clamps schedule retention override when override is below workspace retention floor', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'backup', + 'key' => 'retention_min_floor', + 'value' => 3, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $schedule = makeBackupScheduleForLifecycle($tenant, [ + 'name' => 'Floor clamp override', + 'retention_keep_last' => 1, + ]); + + $sets = collect(range(1, 5))->map(function (int $index) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Clamp Override '.$index, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(10 - $index), + ]); + }); + + $completedAt = now('UTC')->startOfMinute()->subMinutes(6); + + foreach ($sets as $set) { + OperationRun::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'backup_schedule_run', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'lifecycle-floor-override:'.$schedule->id.':'.$set->id), + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [ + 'backup_schedule_id' => (int) $schedule->id, + 'backup_set_id' => (int) $set->id, + ], + 'started_at' => $completedAt, + 'completed_at' => $completedAt, + ]); + + $completedAt = $completedAt->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync((int) $schedule->id); + + $keptIds = BackupSet::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->whereNull('deleted_at') + ->orderBy('id') + ->pluck('id') + ->all(); + + expect($keptIds)->toHaveCount(3); +}); diff --git a/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php b/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php index 001d89a..086964e 100644 --- a/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php +++ b/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php @@ -3,12 +3,13 @@ use App\Models\Finding; use App\Models\Policy; use App\Models\PolicyVersion; +use App\Models\WorkspaceSetting; use App\Services\Drift\DriftFindingGenerator; -test('it creates a drift finding when policy snapshot changes', function () { +test('uses medium severity for drift findings when no severity mapping exists', function () { [, $tenant] = createUserWithTenant(role: 'manager'); - $scopeKey = hash('sha256', 'scope-policy-snapshot'); + $scopeKey = hash('sha256', 'scope-policy-snapshot-default-severity'); $baseline = createInventorySyncOperationRun($tenant, [ 'selection_hash' => $scopeKey, @@ -60,6 +61,7 @@ ->first(); expect($finding)->not->toBeNull(); + expect($finding->severity)->toBe(Finding::SEVERITY_MEDIUM); expect($finding->subject_external_id)->toBe($policy->external_id); expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified'); expect($finding->evidence_jsonb) @@ -70,3 +72,69 @@ ->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash') ->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash'); }); + +test('applies workspace drift severity mapping when configured', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'domain' => 'drift', + 'key' => 'severity_mapping', + 'value' => ['drift' => 'critical'], + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $scopeKey = hash('sha256', 'scope-policy-snapshot-mapped-severity'); + + $baseline = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => ['deviceConfiguration']], + 'status' => 'success', + 'finished_at' => now()->subDays(2), + ]); + + $current = createInventorySyncOperationRun($tenant, [ + 'selection_hash' => $scopeKey, + 'selection_payload' => ['policy_types' => ['deviceConfiguration']], + 'status' => 'success', + 'finished_at' => now()->subDay(), + ]); + + $policy = Policy::factory()->for($tenant)->create([ + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows10', + ]); + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $baseline->finished_at->copy()->subMinute(), + 'snapshot' => ['customSettingFoo' => 'Old value'], + 'assignments' => [], + ]); + + PolicyVersion::factory()->for($tenant)->for($policy)->create([ + 'version_number' => 2, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $current->finished_at->copy()->subMinute(), + 'snapshot' => ['customSettingFoo' => 'New value'], + 'assignments' => [], + ]); + + $generator = app(DriftFindingGenerator::class); + $created = $generator->generate($tenant, $baseline, $current, $scopeKey); + + expect($created)->toBe(1); + + $finding = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('subject_type', 'policy') + ->first(); + + expect($finding)->not->toBeNull(); + expect($finding->severity)->toBe(Finding::SEVERITY_CRITICAL); +}); diff --git a/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php b/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php index 370df03..0633f8c 100644 --- a/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php +++ b/tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php @@ -1,6 +1,9 @@ not->toBeNull(); expect($event->withoutOverlapping)->toBeTrue(); }); + +it('prunes operation runs using per-workspace retention settings', function () { + $workspaceA = Workspace::factory()->create(); + $workspaceB = Workspace::factory()->create(); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspaceA->getKey(), + 'domain' => 'operations', + 'key' => 'operation_run_retention_days', + 'value' => 30, + 'updated_by_user_id' => null, + ]); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspaceB->getKey(), + 'domain' => 'operations', + 'key' => 'operation_run_retention_days', + 'value' => 120, + 'updated_by_user_id' => null, + ]); + + $createRun = function (Workspace $workspace, int $ageDays, string $identitySuffix): OperationRun { + return OperationRun::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => null, + 'user_id' => null, + 'initiator_name' => 'System', + 'type' => 'maintenance', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => hash('sha256', 'prune-workspace-'.$workspace->getKey().'-'.$identitySuffix), + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [], + 'started_at' => now()->subDays($ageDays), + 'completed_at' => now()->subDays($ageDays), + 'created_at' => now()->subDays($ageDays), + 'updated_at' => now()->subDays($ageDays), + ]); + }; + + $workspaceAOldRun = $createRun($workspaceA, 45, 'old'); + $workspaceANewRun = $createRun($workspaceA, 10, 'new'); + + $workspaceBOldRun = $createRun($workspaceB, 140, 'old'); + $workspaceBRecentRun = $createRun($workspaceB, 100, 'recent'); + + PruneOldOperationRunsJob::dispatchSync(); + + expect(OperationRun::query()->whereKey((int) $workspaceAOldRun->getKey())->exists())->toBeFalse(); + expect(OperationRun::query()->whereKey((int) $workspaceANewRun->getKey())->exists())->toBeTrue(); + + expect(OperationRun::query()->whereKey((int) $workspaceBOldRun->getKey())->exists())->toBeFalse(); + expect(OperationRun::query()->whereKey((int) $workspaceBRecentRun->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php index fee8cf6..97e4888 100644 --- a/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php @@ -10,11 +10,16 @@ use App\Models\WorkspaceSetting; use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsWriter; +use App\Support\Audit\AuditActionId; use App\Support\Workspaces\WorkspaceContext; use Illuminate\Validation\ValidationException; use Livewire\Livewire; -it('allows workspace managers to save and reset the workspace retention default', function (): void { +/** + * @return array{0: Workspace, 1: User} + */ +function workspaceManagerUser(): array +{ $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -26,17 +31,34 @@ session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); + return [$workspace, $user]; +} + +it('allows workspace managers to save and reset workspace slice settings', function (): void { + [$workspace, $user] = workspaceManagerUser(); + $this->actingAs($user) ->get(WorkspaceSettings::getUrl(panel: 'admin')) ->assertSuccessful(); $component = Livewire::actingAs($user) ->test(WorkspaceSettings::class) - ->assertSet('data.backup_retention_keep_last_default', 30) + ->assertSet('data.backup_retention_keep_last_default', null) + ->assertSet('data.backup_retention_min_floor', null) + ->assertSet('data.drift_severity_mapping', null) + ->assertSet('data.operations_operation_run_retention_days', null) + ->assertSet('data.operations_stuck_run_threshold_minutes', null) ->set('data.backup_retention_keep_last_default', 55) + ->set('data.backup_retention_min_floor', 12) + ->set('data.drift_severity_mapping', '{"drift":"critical"}') + ->set('data.operations_operation_run_retention_days', 120) + ->set('data.operations_stuck_run_threshold_minutes', 60) ->callAction('save') ->assertHasNoErrors() - ->assertSet('data.backup_retention_keep_last_default', 55); + ->assertSet('data.backup_retention_keep_last_default', 55) + ->assertSet('data.backup_retention_min_floor', 12) + ->assertSet('data.operations_operation_run_retention_days', 120) + ->assertSet('data.operations_stuck_run_threshold_minutes', 60); expect(WorkspaceSetting::query() ->where('workspace_id', (int) $workspace->getKey()) @@ -47,10 +69,23 @@ expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) ->toBe(55); + expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor')) + ->toBe(12); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) + ->toBe(['drift' => 'critical']); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'operation_run_retention_days')) + ->toBe(120); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'stuck_run_threshold_minutes')) + ->toBe(60); + $component - ->callAction('reset') + ->set('data.backup_retention_keep_last_default', '') + ->callAction('save') ->assertHasNoErrors() - ->assertSet('data.backup_retention_keep_last_default', 30); + ->assertSet('data.backup_retention_keep_last_default', null); expect(WorkspaceSetting::query() ->where('workspace_id', (int) $workspace->getKey()) @@ -58,19 +93,21 @@ ->where('key', 'retention_keep_last_default') ->exists())->toBeFalse(); - expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default')) - ->toBe(30); + $component + ->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content') + ->callMountedFormComponentAction() + ->assertHasNoErrors() + ->assertSet('data.operations_operation_run_retention_days', null); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'operations') + ->where('key', 'operation_run_retention_days') + ->exists())->toBeFalse(); }); it('rejects unknown setting keys and does not persist or audit changes', function (): void { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(); - - WorkspaceMembership::factory()->create([ - 'workspace_id' => (int) $workspace->getKey(), - 'user_id' => (int) $user->getKey(), - 'role' => 'manager', - ]); + [$workspace, $user] = workspaceManagerUser(); $writer = app(SettingsWriter::class); @@ -81,15 +118,8 @@ expect(AuditLog::query()->count())->toBe(0); }); -it('rejects invalid setting values and does not persist or audit changes', function (): void { - $workspace = Workspace::factory()->create(); - $user = User::factory()->create(); - - WorkspaceMembership::factory()->create([ - 'workspace_id' => (int) $workspace->getKey(), - 'user_id' => (int) $user->getKey(), - 'role' => 'manager', - ]); +it('rejects invalid backup settings bounds and does not persist or audit changes', function (): void { + [$workspace, $user] = workspaceManagerUser(); $writer = app(SettingsWriter::class); @@ -99,6 +129,201 @@ expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 0)) ->toThrow(ValidationException::class); + expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_keep_last_default', 366)) + ->toThrow(ValidationException::class); + + expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_min_floor', 0)) + ->toThrow(ValidationException::class); + + expect(fn () => $writer->updateWorkspaceSetting($user, $workspace, 'backup', 'retention_min_floor', 366)) + ->toThrow(ValidationException::class); + expect(WorkspaceSetting::query()->count())->toBe(0); expect(AuditLog::query()->count())->toBe(0); }); + +it('rejects malformed drift severity mapping JSON on save', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->set('data.drift_severity_mapping', '{invalid-json}') + ->callAction('save') + ->assertHasErrors(['data.drift_severity_mapping']); + + expect(WorkspaceSetting::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('domain', 'drift') + ->where('key', 'severity_mapping') + ->exists())->toBeFalse(); +}); + +it('rejects invalid drift severity mapping shape and values', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $writer = app(SettingsWriter::class); + + expect(fn () => $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'drift', + key: 'severity_mapping', + value: [123 => 'low'], + ))->toThrow(ValidationException::class); + + expect(fn () => $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'drift', + key: 'severity_mapping', + value: ['drift' => 'urgent'], + ))->toThrow(ValidationException::class); + + $writer->updateWorkspaceSetting( + actor: $user, + workspace: $workspace, + domain: 'drift', + key: 'severity_mapping', + value: ['drift' => 'CRITICAL'], + ); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'drift', 'severity_mapping')) + ->toBe(['drift' => 'critical']); +}); + +it('saves and resets operations settings keys', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->set('data.operations_operation_run_retention_days', 365) + ->set('data.operations_stuck_run_threshold_minutes', 45) + ->callAction('save') + ->assertHasNoErrors() + ->assertSet('data.operations_operation_run_retention_days', 365) + ->assertSet('data.operations_stuck_run_threshold_minutes', 45) + ->mountFormComponentAction('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content') + ->callMountedFormComponentAction() + ->assertHasNoErrors() + ->assertSet('data.operations_stuck_run_threshold_minutes', null); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'operation_run_retention_days')) + ->toBe(365); + + expect(app(SettingsResolver::class)->resolveValue($workspace, 'operations', 'stuck_run_threshold_minutes')) + ->toBe(0); +}); + +it('requires confirmation for each per-setting reset action', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'backup', + 'key' => 'retention_keep_last_default', + 'value' => 40, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'backup', + 'key' => 'retention_min_floor', + 'value' => 5, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'drift', + 'key' => 'severity_mapping', + 'value' => ['drift' => 'low'], + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'operations', + 'key' => 'operation_run_retention_days', + 'value' => 120, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + WorkspaceSetting::query()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'domain' => 'operations', + 'key' => 'stuck_run_threshold_minutes', + 'value' => 30, + 'updated_by_user_id' => (int) $user->getKey(), + ]); + + $component = Livewire::actingAs($user)->test(WorkspaceSettings::class); + + $component + ->mountFormComponentAction('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content'); + expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); + $component->unmountFormComponentAction(); + + $component + ->mountFormComponentAction('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content'); + expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); + $component->unmountFormComponentAction(); + + $component + ->mountFormComponentAction('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content'); + expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); + $component->unmountFormComponentAction(); + + $component + ->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content'); + expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); + $component->unmountFormComponentAction(); + + $component + ->mountFormComponentAction('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content'); + expect($component->instance()->getMountedAction()?->isConfirmationRequired())->toBeTrue(); + $component->unmountFormComponentAction(); +}); + +it('emits one audit entry per key changed when saving multiple settings at once', function (): void { + [$workspace, $user] = workspaceManagerUser(); + + $this->actingAs($user) + ->get(WorkspaceSettings::getUrl(panel: 'admin')) + ->assertSuccessful(); + + Livewire::actingAs($user) + ->test(WorkspaceSettings::class) + ->set('data.backup_retention_keep_last_default', 50) + ->set('data.backup_retention_min_floor', 10) + ->set('data.operations_operation_run_retention_days', 120) + ->callAction('save') + ->assertHasNoErrors(); + + $updatedEvents = AuditLog::query() + ->where('workspace_id', (int) $workspace->getKey()) + ->where('action', AuditActionId::WorkspaceSettingUpdated->value) + ->get(); + + expect($updatedEvents)->toHaveCount(3); + + $keys = $updatedEvents + ->map(fn (AuditLog $auditLog): ?string => data_get($auditLog->metadata, 'key')) + ->filter(fn (?string $key): bool => is_string($key)) + ->values() + ->all(); + + expect($keys)->toEqualCanonicalizing([ + 'retention_keep_last_default', + 'retention_min_floor', + 'operation_run_retention_days', + ]); +}); diff --git a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php index 1566282..cf4b1f5 100644 --- a/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php +++ b/tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php @@ -11,7 +11,7 @@ use App\Support\Workspaces\WorkspaceContext; use Livewire\Livewire; -it('allows view-only members to view workspace settings but forbids save and reset mutations', function (): void { +it('allows view-only members to view workspace settings but forbids save and per-setting reset mutations', function (): void { $workspace = Workspace::factory()->create(); $user = User::factory()->create(); @@ -38,16 +38,28 @@ Livewire::actingAs($user) ->test(WorkspaceSettings::class) ->assertSet('data.backup_retention_keep_last_default', 27) + ->assertSet('data.backup_retention_min_floor', null) + ->assertSet('data.drift_severity_mapping', null) + ->assertSet('data.operations_operation_run_retention_days', null) + ->assertSet('data.operations_stuck_run_threshold_minutes', null) ->assertActionVisible('save') ->assertActionDisabled('save') - ->assertActionVisible('reset') - ->assertActionDisabled('reset') + ->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content') + ->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content') + ->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content') + ->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content') + ->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content') + ->assertFormComponentActionDisabled('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content') + ->assertFormComponentActionVisible('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content') + ->assertFormComponentActionDisabled('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content') + ->assertFormComponentActionVisible('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content') + ->assertFormComponentActionDisabled('operations_stuck_run_threshold_minutes', 'reset_operations_stuck_run_threshold_minutes', [], 'content') ->call('save') ->assertStatus(403); Livewire::actingAs($user) ->test(WorkspaceSettings::class) - ->call('resetSetting') + ->call('resetSetting', 'backup_retention_keep_last_default') ->assertStatus(403); expect(AuditLog::query()->count())->toBe(0); diff --git a/tests/Unit/Badges/FindingBadgesTest.php b/tests/Unit/Badges/FindingBadgesTest.php index 40902b8..e75fd30 100644 --- a/tests/Unit/Badges/FindingBadgesTest.php +++ b/tests/Unit/Badges/FindingBadgesTest.php @@ -17,6 +17,10 @@ $high = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'high'); expect($high->label)->toBe('High'); expect($high->color)->toBe('danger'); + + $critical = BadgeCatalog::spec(BadgeDomain::FindingSeverity, 'critical'); + expect($critical->label)->toBe('Critical'); + expect($critical->color)->toBe('danger'); }); it('maps finding status values to canonical badge semantics', function (): void {