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/specs/098-settings-slices-v1-backup-drift-ops/checklists/requirements.md b/specs/098-settings-slices-v1-backup-drift-ops/checklists/requirements.md new file mode 100644 index 0000000..88cab7b --- /dev/null +++ b/specs/098-settings-slices-v1-backup-drift-ops/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: 098 — Settings Slices v1 (Backup + Drift + Operations) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-16 +**Feature**: [specs/098-settings-slices-v1-backup-drift-ops/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass: 1/1 +- This spec intentionally includes canonical setting keys and value constraints as part of the functional contract. diff --git a/specs/098-settings-slices-v1-backup-drift-ops/plan.md b/specs/098-settings-slices-v1-backup-drift-ops/plan.md new file mode 100644 index 0000000..a87da14 --- /dev/null +++ b/specs/098-settings-slices-v1-backup-drift-ops/plan.md @@ -0,0 +1,142 @@ +# Implementation Plan: 098 — Settings Slices v1 (Backup + Drift + Operations) + +**Branch**: `098-settings-slices-v1-backup-drift-ops` | **Date**: 2026-02-16 | **Spec**: `specs/098-settings-slices-v1-backup-drift-ops/spec.md` +**Input**: Feature specification from `specs/098-settings-slices-v1-backup-drift-ops/spec.md` + +## Summary + +Extend the existing Settings Foundation to expose five additional workspace-level keys on the Workspace Settings page, with strict validation, per-setting reset-to-default (confirmed), and audit logging per key changed. + +Apply those settings to three behavior paths: + +- Backup retention: enforce a workspace-configurable retention default and a workspace-configurable retention floor (effective values clamped up to the floor). +- Drift severities: allow workspace-configurable `finding_type → severity` mapping (default `medium`, values normalized). +- Operations: allow workspace-configurable operation run retention days for pruning and store a “stuck run threshold” value (storage only; no new auto-remediation behavior). + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Laravel Sail +**Storage**: PostgreSQL (Sail local) +**Testing**: Pest v4 (PHPUnit 12 runner) +**Target Platform**: Web app (Filament admin panel) +**Project Type**: Laravel monolith (Filament pages + services + jobs) +**Performance Goals**: Settings resolution should remain request-local cached (no repeated DB reads per key within a request). +**Constraints**: +- DB-only rendering for settings UI (no Graph calls as a render side-effect) +- Strict workspace isolation (non-member 404) +- Capability-gated mutations (member without capability 403) +- Destructive-like resets require confirmation +- Audit each successful mutation; multi-key save produces one audit entry per key changed + +## Baselines to Preserve (When Unset) + +These are the “no settings configured” behaviors that must remain unchanged and covered by regression tests. + +Record the baseline values explicitly so “no change” remains mechanically verifiable over time: + +- Backup retention default: + - When a schedule has no `retention_keep_last`, the job resolves `backup.retention_keep_last_default` and falls back to `30` if unresolved/non-numeric. + - Current clamp behavior: values < 1 are clamped up to 1. + - Source: `app/Jobs/ApplyBackupScheduleRetentionJob.php`. + - Current system default: `backup.retention_keep_last_default` is `30` in `app/Support/Settings/SettingsRegistry.php`. +- Drift default severity: + - Drift findings currently default to `Finding::SEVERITY_MEDIUM`. + - Source: `app/Services/Drift/DriftFindingGenerator.php`. +- Operation run pruning: + - Prune job default retention is `90` days (`new PruneOldOperationRunsJob()` with default constructor argument). + - Source: `app/Jobs/PruneOldOperationRunsJob.php` and schedule in `routes/console.php`. +- “Stuck run threshold”: + - No baseline behavior exists today; for this feature it remains storage-only (must not introduce auto-remediation). + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after design/edits.* + +- DB-only rendering: PASS (Workspace Settings UI is DB-only). +- Graph contract path: PASS (no Graph calls introduced). +- RBAC-UX semantics: PASS-BY-DESIGN (non-member 404; member missing capability 403; server-side still authoritative). +- Destructive-like confirmation: PASS-BY-DESIGN (per-setting reset actions must require confirmation). +- Auditability: PASS-BY-DESIGN (settings writes are audited per key). +- Filament Action Surface Contract: PASS (page-level action surface is explicitly declared in spec via UI Action Matrix). + +## Project Structure + +### Documentation (this feature) + +```text +specs/098-settings-slices-v1-backup-drift-ops/ +├── plan.md +├── spec.md +├── tasks.md +└── checklists/ + └── requirements.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/Pages/Settings/WorkspaceSettings.php +├── Jobs/ +│ ├── ApplyBackupScheduleRetentionJob.php +│ └── PruneOldOperationRunsJob.php +├── Models/Finding.php +├── Services/Drift/DriftFindingGenerator.php +└── Support/ + ├── Badges/Domains/FindingSeverityBadge.php + └── Settings/SettingsRegistry.php + +routes/console.php + +tests/Feature/ +├── BackupScheduling/BackupScheduleLifecycleTest.php +├── Drift/DriftPolicySnapshotDriftDetectionTest.php +├── Scheduling/PruneOldOperationRunsScheduleTest.php +└── SettingsFoundation/ + ├── WorkspaceSettingsManageTest.php + ├── WorkspaceSettingsViewOnlyTest.php + └── WorkspaceSettingsNonMemberNotFoundTest.php +``` + +**Structure Decision**: Use existing Laravel structure only. No new top-level directories. + +## Plan Phases + +### Phase 0 — Align the Registry + UI primitives (shared) + +- Ensure Settings registry rules match the spec (notably the backup max bounds). +- Refactor the Workspace Settings page to support: + - Per-setting reset actions (no global reset) + - Unset inputs with helper text showing the effective/default value + - Save semantics that can “unset” (delete override) for a single key +- Update the existing SettingsFoundation tests to reflect the new UX primitives. + +### Phase 1 — US1 Backup slice (MVP) + +- Add `backup.retention_min_floor` to the registry and UI. +- Apply floor clamping in the retention job to both schedule overrides and workspace defaults. +- Add/extend BackupScheduling + SettingsFoundation tests to cover both baseline behavior and clamping. + +### Phase 2 — US2 Drift slice + +- Add `drift.severity_mapping` to the registry and UI with strict JSON shape validation. +- Normalize severity values to lowercase on save; reject unsupported severities. +- Apply mapping in drift finding generation with default `medium` fallback. +- Add drift tests for default + mapped severity, and settings validation tests. + +### Phase 3 — US3 Operations slice + +- Add operations keys to the registry and UI. +- Wire pruning job to use the configured retention days when set (baseline otherwise). +- Ensure “stuck threshold” is stored only (no new behavior in this feature). +- Add pruning job/schedule tests and settings persistence tests. + +### Phase 4 — Format + focused regression + +- Run Pint for touched files. +- Run the focused test set for SettingsFoundation + BackupScheduling + Drift + Scheduling. + +## Complexity Tracking + +No constitution violations are required for this feature. diff --git a/specs/098-settings-slices-v1-backup-drift-ops/spec.md b/specs/098-settings-slices-v1-backup-drift-ops/spec.md new file mode 100644 index 0000000..1cd5143 --- /dev/null +++ b/specs/098-settings-slices-v1-backup-drift-ops/spec.md @@ -0,0 +1,160 @@ +# Feature Specification: 098 — Settings Slices v1 (Backup + Drift + Operations) + +**Feature Branch**: `098-settings-slices-v1-backup-drift-ops` +**Created**: 2026-02-16 +**Status**: Draft +**Input**: Workspace-level settings slices for Backup, Drift severity mapping, and Operations retention/thresholds with safe defaults. + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: Workspace Settings screen (admin UI) +- **Data Ownership**: workspace-owned settings values (key/value) + audit log entries for setting changes +- **RBAC**: workspace membership required; view vs manage capability-gated; deny-as-not-found for non-members + +## Clarifications + +### Session 2026-02-16 + +- Q: Which “Reset to default” scope do you want on the Workspace Settings UI? → A: Per-setting reset (each key individually). +- Q: When an admin clicks “Save” and multiple settings change at once, how should audit logging behave? → A: One audit log entry per key changed. +- Q: Should we enforce a validation constraint between `backup.retention_keep_last_default` and `backup.retention_min_floor`? → A: Allow any values; effective retention is always clamped to `backup.retention_min_floor`. +- Q: For settings that are currently unset (so the system uses defaults), how should the UI present them? → A: Leave input unset, show helper text with the default/effective value. +- Q: In `drift.severity_mapping`, should severity values be case-sensitive? → A: Case-insensitive; normalize to lowercase on save. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Configure backup retention defaults (Priority: P1) + +As a workspace admin, I want to configure workspace-wide defaults for backup retention so that enterprise workspaces can tune policy without code/config changes. + +**Why this priority**: Backup retention is a common enterprise requirement and impacts storage cost and governance. + +**Independent Test**: Can be tested by setting/unsetting workspace settings and verifying backup retention behavior remains unchanged by default and changes deterministically when configured. + +**Acceptance Scenarios**: + +1. **Given** no workspace settings exist for backup retention, **When** a backup retention decision is made, **Then** behavior matches the current baseline (no change). +2. **Given** a workspace default retention value is configured, **When** a backup retention decision is made without a more specific override, **Then** the configured default is used. +3. **Given** a retention “floor” value is configured, **When** any calculated retention value is below the floor, **Then** the effective retention is clamped up to the floor. +4. **Given** a per-schedule override is configured, **When** that override is below the configured floor, **Then** the override is clamped up to the floor. + +--- + +### User Story 2 - Configure drift severity mapping (Priority: P2) + +As a workspace admin, I want to map drift finding types to severities so that findings align with enterprise risk posture and triage practices. + +**Why this priority**: Severity directly affects how teams triage drift; a one-size-fits-all default is too rigid for enterprise. + +**Independent Test**: Can be tested by saving a mapping, generating findings for mapped/unmapped types, and verifying severities are assigned correctly. + +**Acceptance Scenarios**: + +1. **Given** no drift severity mapping exists, **When** a drift finding is produced, **Then** its severity defaults to “medium”. +2. **Given** a mapping exists for a specific finding type, **When** a drift finding with that type is produced, **Then** the mapped severity is applied. +3. **Given** a mapping contains an unknown/unsupported severity value, **When** an admin attempts to save it, **Then** the save is rejected and no invalid values are persisted. + +--- + +### User Story 3 - Configure operations retention and stuck threshold (Priority: P3) + +As a workspace admin, I want to configure retention for operations/run records and store a “stuck run” threshold so that data lifecycle and operational heuristics are workspace-tunable. + +**Why this priority**: Retention policies and operational thresholds vary widely between organizations and audit requirements. + +**Independent Test**: Can be tested by saving retention/threshold settings and verifying the retention cutoff used by pruning changes accordingly while no new automatic actions occur. + +**Acceptance Scenarios**: + +1. **Given** no operations retention setting exists, **When** operations/run pruning is executed, **Then** the cutoff matches the current baseline (no change). +2. **Given** an operations retention setting exists, **When** pruning is executed, **Then** the cutoff is derived from the configured retention days. +3. **Given** a stuck threshold is configured, **When** the Workspace Settings screen is re-opened, **Then** the configured value is shown exactly as saved. +4. **Given** a stuck threshold is configured, **When** operations/run behavior is observed, **Then** no automatic remediation or auto-handling is performed in this feature scope. + +### Edge Cases + +- Attempting to save invalid numeric ranges (too low/high) is rejected and does not persist. +- Attempting to save invalid JSON (malformed) for drift mapping is rejected. +- Attempting to save drift mapping with non-string keys is rejected. +- Two admins editing settings concurrently results in deterministic persisted state (last write wins) and both attempts are auditable. +- A user without manage capability can view settings (read-only) but cannot submit changes. +- A non-member cannot discover the Workspace Settings screen or any values (deny-as-not-found). + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature MUST remain DB-only for screen rendering and MUST NOT introduce Microsoft Graph calls as part of rendering or saving these settings. Any setting mutation MUST be auditable. + +### Dependencies & Assumptions + +- This feature depends on an existing workspace settings foundation that provides: workspace-scoped storage, consistent defaults, centralized validation, RBAC capability enforcement, and audit logging for changes. +- No tenant-specific override UI is included in v1; only workspace-wide configuration is in scope. +- The default values for all settings keys match the current baseline behavior at the time this feature ships. + +**Constitution alignment (RBAC-UX):** + +- Authorization planes involved: workspace-scoped admin UI. +- Non-member / not entitled to workspace scope → 404 (deny-as-not-found). +- Member but missing manage capability → 403 on mutation attempts; UI remains read-only. + +**Constitution alignment (OPS/observability):** This feature does not introduce a new long-running operation type; it changes which configuration values are used by existing behavior. All admin-initiated mutations MUST produce audit log entries. + +### Functional Requirements + +- **FR-001**: System MUST support workspace-level configuration for the following setting keys: + - `backup.retention_keep_last_default` + - `backup.retention_min_floor` + - `drift.severity_mapping` + - `operations.operation_run_retention_days` + - `operations.stuck_run_threshold_minutes` +- **FR-002**: System MUST preserve existing behavior when none of the above settings are configured (defaults MUST match the current baseline). +- **FR-003**: System MUST validate and reject invalid setting values, ensuring no invalid configuration is silently persisted. +- **FR-004**: Workspace Settings UI MUST present the above keys grouped into three sections (Backup, Drift, Operations) and MUST be fully functional without any external API calls. +- **FR-004a**: For any setting key that is currently unset, the UI MUST keep the input in an “unset” state and MUST display helper text indicating the default (currently effective) value. +- **FR-005**: Users with view capability MUST be able to view current effective settings but MUST NOT be able to change them. +- **FR-006**: Users with manage capability MUST be able to change settings and reset individual settings back to defaults. +- **FR-007**: Resetting a setting to default MUST be a confirmed (destructive-like) action. +- **FR-008**: System MUST write audit log entries for each settings update and reset-to-default event including: workspace identity, actor identity, setting key, old value, new value, and timestamp. +- **FR-008a**: When a single save operation changes multiple keys, the system MUST write one audit log entry per key changed. + +#### Backup slice requirements + +- **FR-009**: `backup.retention_keep_last_default` MUST be an integer between 1 and 365. +- **FR-010**: `backup.retention_min_floor` MUST be an integer between 1 and 365. +- **FR-011**: Effective backup retention MUST never be lower than `backup.retention_min_floor` (applies to defaults and any more specific overrides). +- **FR-011a**: The system MUST NOT reject configuration solely because `backup.retention_min_floor` exceeds `backup.retention_keep_last_default`; instead, the effective retention MUST be clamped to the floor. + +#### Drift slice requirements + +- **FR-012**: `drift.severity_mapping` MUST be a JSON object mapping `finding_type` (string) → severity. +- **FR-013**: Allowed severity values MUST be limited to: `low`, `medium`, `high`, `critical`. +- **FR-013a**: Severity values MUST be accepted case-insensitively and normalized to lowercase when persisted. +- **FR-014**: If a finding type has no mapping entry, severity MUST default to `medium`. + +#### Operations slice requirements + +- **FR-015**: `operations.operation_run_retention_days` MUST be an integer between 7 and 3650. +- **FR-016**: Pruning of operations/run records MUST use the configured retention days when set; otherwise it MUST behave as the baseline. +- **FR-017**: `operations.stuck_run_threshold_minutes` MUST be an integer between 0 and 10080. +- **FR-018**: `operations.stuck_run_threshold_minutes` MUST be a stored configuration value only; it MUST NOT introduce auto-remediation or auto-handling behavior in this feature scope. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Page | Workspace Settings (admin UI) | Save; Reset setting to default (per-setting, confirmed) | N/A | N/A | N/A | N/A | N/A | Save + Cancel | Yes | View capability: read-only fields; Manage capability: editable + submit; Non-member: 404 | + +### Key Entities *(include if feature involves data)* + +- **Workspace Setting**: A workspace-owned key/value configuration item with validation rules and a default. +- **Audit Log Entry**: An immutable record of a settings update or reset-to-default event. +- **Drift Finding Type**: A classification string used to identify the type of drift finding for severity mapping. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: When no settings are configured for these keys, backup retention behavior, drift severity behavior, and operations pruning behavior match the baseline in automated regression tests. +- **SC-002**: An authorized workspace admin can update each of the 5 settings and observe the new effective value reflected in the Workspace Settings screen immediately after save. +- **SC-003**: 100% of settings updates and reset-to-default actions produce an audit log entry with key, old value, and new value. +- **SC-004**: Invalid configuration attempts (out-of-range numbers, invalid JSON, unsupported severities) are rejected and do not change persisted settings. diff --git a/specs/098-settings-slices-v1-backup-drift-ops/tasks.md b/specs/098-settings-slices-v1-backup-drift-ops/tasks.md new file mode 100644 index 0000000..38a4cd1 --- /dev/null +++ b/specs/098-settings-slices-v1-backup-drift-ops/tasks.md @@ -0,0 +1,122 @@ +--- + +description: "Task list for 098-settings-slices-v1-backup-drift-ops" + +--- + +# Tasks: 098 — Settings Slices v1 (Backup + Drift + Operations) + +**Input**: Design documents from `/specs/098-settings-slices-v1-backup-drift-ops/` (spec.md, plan.md) +**Tests**: REQUIRED (Pest) — runtime behavior changes. +**Scope**: workspace-level settings; DB-only rendering; no Graph calls. + +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 Confirm feature branch + clean working tree in specs/098-settings-slices-v1-backup-drift-ops/spec.md +- [X] T002 Verify Settings Foundation dependency is present by locating SettingsRegistry in app/Support/Settings/SettingsRegistry.php +- [X] T003 [P] Capture baseline behaviors + constants and record them in specs/098-settings-slices-v1-backup-drift-ops/plan.md by reviewing app/Jobs/ApplyBackupScheduleRetentionJob.php, app/Services/Drift/DriftFindingGenerator.php, app/Jobs/PruneOldOperationRunsJob.php, routes/console.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared settings primitives + Workspace Settings page patterns used by all slices. + +- [X] T004 Update Settings registry validation to match spec (max 365) in app/Support/Settings/SettingsRegistry.php +- [X] T005 [P] Add per-setting reset UX pattern scaffolding (no global reset) in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T006 Add “unset input + helper text shows default/effective” support in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T007 [P] Update existing workspace settings RBAC tests for new per-setting reset actions in tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php +- [X] T008 [P] Update existing manage test to assert per-setting reset (not header reset) in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php + +**Checkpoint**: Workspace Settings page supports per-setting resets and unset presentation without changing behavior. + +--- + +## Phase 3: User Story 1 — Backup retention defaults (Priority: P1) 🎯 MVP + +**Goal**: Workspace overrides for backup retention default + min floor; job clamps effective keep-last to floor. +**Independent Test**: Run ApplyBackupScheduleRetentionJob behavior with/without workspace overrides and verify clamping. + +### Tests (US1) + +- [X] T009 [P] [US1] Add/extend retention job tests to cover default + floor clamp in tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php +- [X] T010 [P] [US1] Add validation tests for backup settings bounds (1..365) via SettingsWriter in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php + +### Implementation (US1) + +- [X] T011 [US1] Register backup floor setting and tighten keep-last rules (1..365) in app/Support/Settings/SettingsRegistry.php +- [X] T012 [US1] Extend Workspace Settings UI: Backup section adds `backup.retention_keep_last_default` + `backup.retention_min_floor` fields with per-setting reset actions in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T013 [US1] Update Workspace Settings save logic: empty field triggers reset (delete override) instead of persisting null in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T014 [US1] Apply floor clamping for both schedule override and resolved default in app/Jobs/ApplyBackupScheduleRetentionJob.php + +--- + +## Phase 4: User Story 2 — Drift severity mapping (Priority: P2) + +**Goal**: Workspace-level `finding_type → severity` mapping with default `medium` and strict validation; normalize severities to lowercase. +**Independent Test**: Generate drift findings and assert severity uses mapping when present; saving invalid mapping is rejected. + +### Tests (US2) + +- [X] T015 [P] [US2] Add drift generator test asserting default severity remains medium when no mapping set in tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php +- [X] T016 [P] [US2] Add drift generator test asserting mapped severity is applied when mapping exists in tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php +- [X] T017 [P] [US2] Add settings save validation tests for drift severity mapping JSON shape + allowed values in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php + +### Implementation (US2) + +- [X] T018 [US2] Add `Finding::SEVERITY_CRITICAL` constant and ensure severity domain remains stable in app/Models/Finding.php +- [X] T019 [US2] Extend finding severity badge mapping to include `critical` (BADGE-001 compliant) in app/Support/Badges/Domains/FindingSeverityBadge.php +- [X] T020 [US2] Register `drift.severity_mapping` setting with JSON validation + canonical normalization (lowercase values, string keys) in app/Support/Settings/SettingsRegistry.php +- [X] T021 [US2] Update DriftFindingGenerator to resolve workspace severity mapping (via SettingsResolver in workspace context) and apply mapped severity (fallback medium) in app/Services/Drift/DriftFindingGenerator.php +- [X] T022 [US2] Extend Workspace Settings UI: Drift section adds JSON textarea for `drift.severity_mapping` with unset behavior + per-setting reset in app/Filament/Pages/Settings/WorkspaceSettings.php + +--- + +## Phase 5: User Story 3 — Operations retention + stuck threshold (Priority: P3) + +**Goal**: Workspace-level `operations.operation_run_retention_days` drives pruning; `operations.stuck_run_threshold_minutes` is stored only. +**Independent Test**: Create old/new OperationRuns across workspaces and verify prune respects per-workspace retention; stuck threshold persists and reloads. + +### Tests (US3) + +- [X] T023 [P] [US3] Update pruning schedule test to match new job behavior (per-workspace retention) in tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php +- [X] T024 [P] [US3] Add prune job test verifying per-workspace retention cutoff using workspace settings in tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php +- [X] T025 [P] [US3] Add workspace settings save/reset tests for operations keys in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php + +### Implementation (US3) + +- [X] T026 [US3] Register operations settings keys with correct bounds in app/Support/Settings/SettingsRegistry.php +- [X] T027 [US3] Refactor PruneOldOperationRunsJob to compute retention per workspace (SettingsResolver + Workspace iteration) and prune by workspace_id in app/Jobs/PruneOldOperationRunsJob.php +- [X] T028 [US3] Ensure scheduler continues to enqueue prune job without needing a parameter in routes/console.php +- [X] T029 [US3] Extend Workspace Settings UI: Operations section adds `operation_run_retention_days` + `stuck_run_threshold_minutes` fields with unset behavior + per-setting reset in app/Filament/Pages/Settings/WorkspaceSettings.php + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T030 [P] Confirm multi-key save emits one audit entry per key changed by reviewing app/Services/Settings/SettingsWriter.php and Workspace Settings save flow in app/Filament/Pages/Settings/WorkspaceSettings.php +- [X] T031 [P] Run Pint formatting on touched files via vendor/bin/sail bin pint --dirty (e.g., app/Filament/Pages/Settings/WorkspaceSettings.php, app/Support/Settings/SettingsRegistry.php, app/Jobs/ApplyBackupScheduleRetentionJob.php, app/Services/Drift/DriftFindingGenerator.php, app/Jobs/PruneOldOperationRunsJob.php) +- [X] T032 Run focused settings UI tests via vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php +- [X] T033 Run focused drift tests via vendor/bin/sail artisan test --compact tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php +- [X] T034 Run focused pruning tests via vendor/bin/sail artisan test --compact tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php +- [X] T035 [P] Add an automated regression test asserting per-setting reset actions require confirmation (destructive-like) in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php +- [X] T036 [P] Add an automated regression test asserting multi-key save produces one audit entry per key changed (FR-008a) in tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php + +--- + +## Dependencies & Execution Order + +- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2 (Phase 4) → US3 (Phase 5) → Polish (Phase 6) + +## Parallel Execution Examples + +```text +US1 parallel example: T009 + T011 + T014 +US2 parallel example: T015 + T018 + T021 +US3 parallel example: T023 + T027 + T029 +``` + +## Implementation Strategy + +- MVP = US1 only (backup defaults + floor clamp) with updated Workspace Settings UX and tests. +- Then US2 (drift mapping) and US3 (operations retention/threshold) as independent increments. 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 {