From 88227a9d943eaa514501082498f22e2f5ce7a616 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 31 Dec 2025 19:59:40 +0100 Subject: [PATCH] feat(wizard): Add restore from policy version Implements the "Restore via Wizard" action on the PolicyVersion resource. This allows a user to initiate a restore run directly from a specific policy version snapshot. - Adds a "Restore via Wizard" action to the PolicyVersion table. - This action creates a single-item BackupSet from the selected version. - The CreateRestoreRun wizard is now pre-filled from query parameters. - Adds feature tests to cover the new workflow. - Updates tasks.md to reflect the completed work. --- .../Resources/PolicyVersionResource.php | 93 ++++++++++ .../Pages/CreateRestoreRun.php | 89 ++++++++++ specs/011-restore-run-wizard/tasks.md | 2 +- .../PolicyVersionRestoreViaWizardTest.php | 164 ++++++++++++++++++ 4 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index bb3e19d..4bab649 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -6,6 +6,8 @@ use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionRestoreJob; +use App\Models\BackupItem; +use App\Models\BackupSet; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\BulkOperationService; @@ -13,6 +15,7 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; use BackedEnum; +use Carbon\CarbonImmutable; use Filament\Actions; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; @@ -183,6 +186,96 @@ public static function table(Table $table): Table ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), Actions\ActionGroup::make([ + Actions\Action::make('restore_via_wizard') + ->label('Restore via Wizard') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('primary') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") + ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') + ->action(function (PolicyVersion $record) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant || $record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + $policy = $record->policy; + + if (! $policy) { + Notification::make() + ->title('Policy could not be found for this version') + ->danger() + ->send(); + + return; + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $record->version_number + ), + 'created_by' => $user?->email, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ + 'source' => 'policy_version', + 'display_name' => $policy->display_name, + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'version_captured_at' => $record->captured_at?->toIso8601String(), + ]; + + if (is_array($scopeTagIds) && $scopeTagIds !== []) { + $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; + } + + if (is_array($scopeTagNames) && $scopeTagNames !== []) { + $backupItemMetadata['scope_tag_names'] = $scopeTagNames; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_version_id' => $record->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), + 'payload' => $record->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $record->assignments, + ]); + + return redirect()->to(RestoreRunResource::getUrl('create', [ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])); + }), Actions\Action::make('archive') ->label('Archive') ->color('danger') diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index d46d355..2e4e6de 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,6 +3,8 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use App\Models\BackupSet; +use App\Models\Tenant; use Filament\Actions\Action; use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; @@ -19,6 +21,93 @@ public function getSteps(): array return RestoreRunResource::getWizardSteps(); } + protected function afterFill(): void + { + $backupSetIdRaw = request()->query('backup_set_id'); + + if (! is_numeric($backupSetIdRaw)) { + return; + } + + $backupSetId = (int) $backupSetIdRaw; + + if ($backupSetId <= 0) { + return; + } + + $tenant = Tenant::current(); + + if (! $tenant) { + return; + } + + $belongsToTenant = BackupSet::query() + ->where('tenant_id', $tenant->id) + ->whereKey($backupSetId) + ->exists(); + + if (! $belongsToTenant) { + return; + } + + $backupItemIds = $this->normalizeBackupItemIds(request()->query('backup_item_ids')); + $scopeModeRaw = request()->query('scope_mode'); + $scopeMode = in_array($scopeModeRaw, ['all', 'selected'], true) + ? $scopeModeRaw + : ($backupItemIds !== [] ? 'selected' : 'all'); + + $this->data['backup_set_id'] = $backupSetId; + $this->form->callAfterStateUpdated('data.backup_set_id'); + + $this->data['scope_mode'] = $scopeMode; + $this->form->callAfterStateUpdated('data.scope_mode'); + + if ($scopeMode === 'selected') { + if ($backupItemIds !== []) { + $this->data['backup_item_ids'] = $backupItemIds; + } + + $this->form->callAfterStateUpdated('data.backup_item_ids'); + } + } + + /** + * @return array + */ + private function normalizeBackupItemIds(mixed $raw): array + { + if (is_string($raw)) { + $raw = array_filter(array_map('trim', explode(',', $raw))); + } + + if (! is_array($raw)) { + return []; + } + + $itemIds = []; + + foreach ($raw as $value) { + if (is_int($value) && $value > 0) { + $itemIds[] = $value; + + continue; + } + + if (is_string($value) && ctype_digit($value)) { + $itemId = (int) $value; + + if ($itemId > 0) { + $itemIds[] = $itemId; + } + } + } + + $itemIds = array_values(array_unique($itemIds)); + sort($itemIds); + + return $itemIds; + } + protected function getSubmitFormAction(): Action { return parent::getSubmitFormAction() diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index be82696..39cbde8 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -42,4 +42,4 @@ ## Phase 7 — Tests + Formatting - [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). ## Phase 8 — Policy Version Entry Point (later) -- [ ] T023 Add a “Restore via Wizard” action on `PolicyVersion` that creates a 1-item Backup Set (source = policy_version) and opens the Restore Run wizard prefilled/scoped to that item. +- [x] T023 Add a “Restore via Wizard” action on `PolicyVersion` that creates a 1-item Backup Set (source = policy_version) and opens the Restore Run wizard prefilled/scoped to that item. diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php new file mode 100644 index 0000000..7a84965 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -0,0 +1,164 @@ + 'tenant-policy-version-wizard', + 'name' => 'Tenant', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog', + 'platform' => 'windows', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 3, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name], + 'assignments' => [['intent' => 'apply']], + 'scope_tags' => [ + 'ids' => ['st-1'], + 'names' => ['Tag 1'], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + Livewire::test(ListPolicyVersions::class) + ->callTableAction('restore_via_wizard', $version) + ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false)); + + $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); + expect($backupSet)->not->toBeNull(); + expect($backupSet->tenant_id)->toBe($tenant->id); + expect($backupSet->metadata['policy_version_id'] ?? null)->toBe($version->id); + + $backupItem = BackupItem::query()->where('backup_set_id', $backupSet->id)->first(); + expect($backupItem)->not->toBeNull(); + expect($backupItem->policy_version_id)->toBe($version->id); + expect($backupItem->policy_identifier)->toBe($policy->external_id); + expect($backupItem->metadata['scope_tag_ids'] ?? null)->toBe(['st-1']); + expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']); +}); + +test('restore run wizard can be prefilled from query params for policy version backup set', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-policy-version-prefill', + 'name' => 'Tenant', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog', + 'platform' => 'windows', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + 'group_display_name' => 'Source Group', + ], + 'intent' => 'apply', + ]], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Policy Version Restore', + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $version->id, + ], + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_version_id' => $version->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $version->snapshot ?? [], + 'assignments' => $version->assignments, + ]); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->andReturnUsing(function (array $groupIds): array { + return collect($groupIds) + ->mapWithKeys(fn (string $id) => [$id => [ + 'id' => $id, + 'displayName' => null, + 'orphaned' => true, + ]]) + ->all(); + }); + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + $component = Livewire::withQueryParams([ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])->test(CreateRestoreRun::class); + + expect($component->get('data.backup_set_id'))->toBe($backupSet->id); + expect($component->get('data.scope_mode'))->toBe('selected'); + expect($component->get('data.backup_item_ids'))->toBe([$backupItem->id]); + + $mapping = $component->get('data.group_mapping'); + expect($mapping)->toBeArray(); + expect(array_key_exists('source-group-1', $mapping))->toBeTrue(); + expect($mapping['source-group-1'])->toBeNull(); + + $component + ->goToNextWizardStep() + ->assertFormFieldVisible('group_mapping.source-group-1'); +});