feat/011-restore-run-wizard #37
@ -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')
|
||||
|
||||
@ -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<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
|
||||
{
|
||||
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).
|
||||
|
||||
## 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