feat: Workspace settings slices v1 (backup, drift, operations) (#120)

Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold.

Spec
- specs/098-settings-slices-v1-backup-drift-ops/spec.md

What changed
- Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed)
- Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase)
- Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor
- Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping
- Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only

Safety / Compliance notes
- Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php)
- Destructive actions: per-setting reset uses Filament actions with confirmation
- Global search: not affected (no resource changes)
- Assets: no new assets registered; no `filament:assets` changes

Tests
- vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \
  tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \
  tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \
  tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \
  tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \
  tests/Unit/Badges/FindingBadgesTest.php

Formatting
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #120
This commit is contained in:
ahmido 2026-02-16 03:18:33 +00:00
parent e241e27853
commit c57f680f39
19 changed files with 1661 additions and 90 deletions

View File

@ -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<string, array{domain: string, key: string, type: 'int'|'json'}>
*/
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<string, mixed>
*/
public array $workspaceOverrides = [];
/**
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
*/
public array $resolvedSettings = [];
/**
* @return array<Action>
*/
@ -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<string, mixed>, 1: array<string, array<int, string>>}
*/
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<mixed> $value
* @return array<mixed>
*/
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

View File

@ -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<int, int> $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(),
],

View File

@ -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();
}
}
}

View File

@ -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';

View File

@ -16,6 +16,10 @@ class TenantSetting extends Model
protected $guarded = [];
protected $casts = [
'value' => 'array',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);

View File

@ -14,6 +14,10 @@ class WorkspaceSetting extends Model
protected $guarded = [];
protected $casts = [
'value' => 'array',
];
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);

View File

@ -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<int, string>
*/
private function supportedSeverities(): array
{
return [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
];
}
}

View File

@ -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(),
};
}

View File

@ -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<int, string>
*/
private static function supportedFindingSeverities(): array
{
return [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
];
}
/**
* @return array<string, string>
*/
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;
}
}

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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);
});

View File

@ -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);
});

View File

@ -1,6 +1,9 @@
<?php
use App\Jobs\PruneOldOperationRunsJob;
use App\Models\OperationRun;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use Illuminate\Console\Scheduling\Schedule;
it('schedules pruning job daily without overlapping', function () {
@ -13,3 +16,58 @@
expect($event)->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();
});

View File

@ -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',
]);
});

View File

@ -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);

View File

@ -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 {