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:
parent
e241e27853
commit
c57f680f39
@ -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
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -16,6 +16,10 @@ class TenantSetting extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
|
||||
@ -14,6 +14,10 @@ class WorkspaceSetting extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
142
specs/098-settings-slices-v1-backup-drift-ops/plan.md
Normal file
142
specs/098-settings-slices-v1-backup-drift-ops/plan.md
Normal 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.
|
||||
160
specs/098-settings-slices-v1-backup-drift-ops/spec.md
Normal file
160
specs/098-settings-slices-v1-backup-drift-ops/spec.md
Normal 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.
|
||||
122
specs/098-settings-slices-v1-backup-drift-ops/tasks.md
Normal file
122
specs/098-settings-slices-v1-backup-drift-ops/tasks.md
Normal 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.
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user