feat(wizard): Add restore from policy version (#15)
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. ## Summary <!-- Kurz: Was ändert sich und warum? --> ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes <!-- Links, Screenshots, Follow-ups, offene Punkte --> Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #15
This commit is contained in:
parent
e1cda6a1dc
commit
e19aa09ae0
@ -6,6 +6,8 @@
|
|||||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
use App\Jobs\BulkPolicyVersionRestoreJob;
|
use App\Jobs\BulkPolicyVersionRestoreJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
@ -13,6 +15,7 @@
|
|||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@ -183,6 +186,96 @@ public static function table(Table $table): Table
|
|||||||
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
Actions\ActionGroup::make([
|
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')
|
Actions\Action::make('archive')
|
||||||
->label('Archive')
|
->label('Archive')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Tenant;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
@ -19,6 +21,93 @@ public function getSteps(): array
|
|||||||
return RestoreRunResource::getWizardSteps();
|
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<int>
|
||||||
|
*/
|
||||||
|
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
|
protected function getSubmitFormAction(): Action
|
||||||
{
|
{
|
||||||
return parent::getSubmitFormAction()
|
return parent::getSubmitFormAction()
|
||||||
|
|||||||
@ -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).
|
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
|
||||||
|
|
||||||
## Phase 8 — Policy Version Entry Point (later)
|
## 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.
|
||||||
|
|||||||
164
tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
Normal file
164
tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GroupResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('policy version can open restore wizard via row action', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => '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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user