diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index f453c81..93093e5 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -6,6 +6,7 @@ use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunRestoreJob; +use App\Jobs\ExecuteRestoreRunJob; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\RestoreRun; @@ -13,9 +14,11 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\AuditLogger; use App\Services\Intune\RestoreDiffGenerator; use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; +use App\Support\RestoreRunStatus; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -37,6 +40,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use UnitEnum; class RestoreRunResource extends Resource @@ -178,6 +182,8 @@ public static function getWizardSteps(): array $set('backup_item_ids', null); $set('group_mapping', []); $set('is_dry_run', true); + $set('acknowledged_impact', false); + $set('tenant_confirm', null); $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); @@ -200,6 +206,9 @@ public static function getWizardSteps(): array ->reactive() ->afterStateUpdated(function (Set $set, $state): void { $set('group_mapping', []); + $set('is_dry_run', true); + $set('acknowledged_impact', false); + $set('tenant_confirm', null); $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); @@ -227,6 +236,9 @@ public static function getWizardSteps(): array ->reactive() ->afterStateUpdated(function (Set $set): void { $set('group_mapping', []); + $set('is_dry_run', true); + $set('acknowledged_impact', false); + $set('tenant_confirm', null); $set('check_summary', null); $set('check_results', []); $set('checks_ran_at', null); @@ -407,6 +419,10 @@ public static function getWizardSteps(): array $blockers = (int) ($summary['blocking'] ?? 0); $warnings = (int) ($summary['warning'] ?? 0); + if ($blockers > 0) { + $set('is_dry_run', true, shouldCallUpdatedHooks: true); + } + Notification::make() ->title('Safety checks completed') ->body("Blocking: {$blockers} • Warnings: {$warnings}") @@ -419,6 +435,9 @@ public static function getWizardSteps(): array ->color('gray') ->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary'))) ->action(function (Set $set): void { + $set('is_dry_run', true, shouldCallUpdatedHooks: true); + $set('acknowledged_impact', false, shouldCallUpdatedHooks: true); + $set('tenant_confirm', null, shouldCallUpdatedHooks: true); $set('check_summary', null, shouldCallUpdatedHooks: true); $set('check_results', [], shouldCallUpdatedHooks: true); $set('checks_ran_at', null, shouldCallUpdatedHooks: true); @@ -429,11 +448,6 @@ public static function getWizardSteps(): array Step::make('Preview') ->description('Dry-run preview') ->schema([ - Forms\Components\Toggle::make('is_dry_run') - ->label('Preview only (dry-run)') - ->default(true) - ->disabled() - ->helperText('Execution will be enabled once checks, preview, and confirmations are implemented (Phase 6).'), Forms\Components\Hidden::make('preview_summary') ->default(null), Forms\Components\Hidden::make('preview_ran_at') @@ -514,6 +528,9 @@ public static function getWizardSteps(): array ->color('gray') ->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary'))) ->action(function (Set $set): void { + $set('is_dry_run', true, shouldCallUpdatedHooks: true); + $set('acknowledged_impact', false, shouldCallUpdatedHooks: true); + $set('tenant_confirm', null, shouldCallUpdatedHooks: true); $set('preview_summary', null, shouldCallUpdatedHooks: true); $set('preview_diffs', [], shouldCallUpdatedHooks: true); $set('preview_ran_at', null, shouldCallUpdatedHooks: true); @@ -522,11 +539,76 @@ public static function getWizardSteps(): array ->helperText('Generate a normalized diff preview before creating the dry-run restore.'), ]), Step::make('Confirm & Execute') - ->description('Explicit confirmations (Phase 6)') + ->description('Point of no return') ->schema([ - Forms\Components\Placeholder::make('confirm_placeholder') - ->label('Execution') - ->content('Execution confirmations and gating will be added in Phase 6.'), + Forms\Components\Placeholder::make('confirm_environment') + ->label('Environment') + ->content(fn (): string => app()->environment('production') ? 'prod' : 'test'), + Forms\Components\Placeholder::make('confirm_tenant_label') + ->label('Tenant hard-confirm label') + ->content(function (): string { + $tenant = Tenant::current(); + + if (! $tenant) { + return ''; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + }), + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true) + ->reactive() + ->disabled(function (Get $get): bool { + if (! filled($get('checks_ran_at'))) { + return true; + } + + $summary = $get('check_summary'); + + if (! is_array($summary)) { + return false; + } + + return (int) ($summary['blocking'] ?? 0) > 0; + }) + ->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'), + Forms\Components\Checkbox::make('acknowledged_impact') + ->label('I reviewed the impact (checks + preview)') + ->accepted() + ->visible(fn (Get $get): bool => $get('is_dry_run') === false), + Forms\Components\TextInput::make('tenant_confirm') + ->label('Type the tenant label to confirm execution') + ->required(fn (Get $get): bool => $get('is_dry_run') === false) + ->visible(fn (Get $get): bool => $get('is_dry_run') === false) + ->in(function (): array { + $tenant = Tenant::current(); + + if (! $tenant) { + return []; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())]; + }) + ->validationMessages([ + 'in' => 'Tenant hard-confirm does not match.', + ]) + ->helperText(function (): string { + $tenant = Tenant::current(); + + if (! $tenant) { + return ''; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + return "Type: {$expected}"; + }), ]), ]; } @@ -1055,19 +1137,13 @@ public static function createRestoreRun(array $data): RestoreRun $service = app(RestoreService::class); $scopeMode = $data['scope_mode'] ?? 'all'; - $selectedItemIds = ($scopeMode === 'selected') - ? ($data['backup_item_ids'] ?? null) - : null; + $selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null; + $selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null; - $restoreRun = $service->execute( - tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, - dryRun: true, - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - groupMapping: $data['group_mapping'] ?? [], - ); + $actorEmail = auth()->user()?->email; + $actorName = auth()->user()?->name; + $isDryRun = (bool) ($data['is_dry_run'] ?? true); + $groupMapping = $data['group_mapping'] ?? []; $checkSummary = $data['check_summary'] ?? null; $checkResults = $data['check_results'] ?? null; @@ -1076,14 +1152,57 @@ public static function createRestoreRun(array $data): RestoreRun $previewDiffs = $data['preview_diffs'] ?? null; $previewRanAt = $data['preview_ran_at'] ?? null; - if ( - is_array($checkSummary) - || is_array($checkResults) - || (is_string($checksRanAt) && $checksRanAt !== '') - || is_array($previewSummary) - || is_array($previewDiffs) - || (is_string($previewRanAt) && $previewRanAt !== '') - ) { + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + if (! $isDryRun) { + if (! is_array($checkSummary) || ! filled($checksRanAt)) { + throw ValidationException::withMessages([ + 'check_summary' => 'Run safety checks before executing.', + ]); + } + + $blocking = (int) ($checkSummary['blocking'] ?? 0); + $hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0)); + + if ($blocking > 0 || $hasBlockers) { + throw ValidationException::withMessages([ + 'check_summary' => 'Blocking checks must be resolved before executing.', + ]); + } + + if (! filled($previewRanAt)) { + throw ValidationException::withMessages([ + 'preview_ran_at' => 'Generate preview before executing.', + ]); + } + + if (! (bool) ($data['acknowledged_impact'] ?? false)) { + throw ValidationException::withMessages([ + 'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.', + ]); + } + + $tenantConfirm = $data['tenant_confirm'] ?? null; + + if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) { + throw ValidationException::withMessages([ + 'tenant_confirm' => 'Tenant hard-confirm does not match.', + ]); + } + } + + if ($isDryRun) { + $restoreRun = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + dryRun: true, + actorEmail: $actorEmail, + actorName: $actorName, + groupMapping: $groupMapping, + ); + $metadata = $restoreRun->metadata ?? []; if (is_array($checkSummary)) { @@ -1110,11 +1229,76 @@ public static function createRestoreRun(array $data): RestoreRun $metadata['preview_ran_at'] = $previewRanAt; } - $restoreRun->update([ - 'metadata' => $metadata, - ]); + $restoreRun->update(['metadata' => $metadata]); + + return $restoreRun->refresh(); } + $preview = $service->preview($tenant, $backupSet, $selectedItemIds); + + $metadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => $highlanderLabel, + 'confirmed_at' => now()->toIso8601String(), + 'confirmed_by' => $actorEmail, + 'confirmed_by_name' => $actorName, + ]; + + if (is_array($checkSummary)) { + $metadata['check_summary'] = $checkSummary; + } + + if (is_array($checkResults)) { + $metadata['check_results'] = $checkResults; + } + + if (is_string($checksRanAt) && $checksRanAt !== '') { + $metadata['checks_ran_at'] = $checksRanAt; + } + + if (is_array($previewSummary)) { + $metadata['preview_summary'] = $previewSummary; + } + + if (is_array($previewDiffs)) { + $metadata['preview_diffs'] = $previewDiffs; + } + + if (is_string($previewRanAt) && $previewRanAt !== '') { + $metadata['preview_ran_at'] = $previewRanAt; + } + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', + ); + + ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); + return $restoreRun->refresh(); } diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 0fff9d4..d46d355 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -22,8 +22,8 @@ public function getSteps(): array protected function getSubmitFormAction(): Action { return parent::getSubmitFormAction() - ->label('Create preview (dry-run)') - ->icon('heroicon-o-eye'); + ->label('Create restore run') + ->icon('heroicon-o-check-circle'); } protected function handleRecordCreation(array $data): Model diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php new file mode 100644 index 0000000..dd57d20 --- /dev/null +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -0,0 +1,134 @@ +find($this->restoreRunId); + + if (! $restoreRun) { + return; + } + + if ($restoreRun->status !== RestoreRunStatus::Queued->value) { + return; + } + + $tenant = $restoreRun->tenant; + $backupSet = $restoreRun->backupSet; + + if (! $tenant || ! $backupSet || $backupSet->trashed()) { + $restoreRun->update([ + 'status' => RestoreRunStatus::Failed->value, + 'failure_reason' => 'Backup set is archived or unavailable.', + 'completed_at' => CarbonImmutable::now(), + ]); + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'restore.failed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $restoreRun->backup_set_id, + 'reason' => 'Backup set is archived or unavailable.', + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'failed', + ); + } + + return; + } + + $restoreRun->update([ + 'status' => RestoreRunStatus::Running->value, + 'started_at' => CarbonImmutable::now(), + 'failure_reason' => null, + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'restore.started', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', + ); + + try { + $restoreService->executeForRun( + restoreRun: $restoreRun, + tenant: $tenant, + backupSet: $backupSet, + actorEmail: $this->actorEmail, + actorName: $this->actorName, + ); + } catch (Throwable $throwable) { + $restoreRun->refresh(); + + if ($restoreRun->status === RestoreRunStatus::Running->value) { + $restoreRun->update([ + 'status' => RestoreRunStatus::Failed->value, + 'failure_reason' => $throwable->getMessage(), + 'completed_at' => CarbonImmutable::now(), + ]); + } + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'restore.failed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'reason' => $throwable->getMessage(), + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'failed', + ); + } + + throw $throwable; + } + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 2b8aaef..e9e02d1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -184,6 +184,45 @@ public function executeFromPolicyVersion( ); } + public function executeForRun( + RestoreRun $restoreRun, + Tenant $tenant, + BackupSet $backupSet, + ?string $actorEmail = null, + ?string $actorName = null, + ): RestoreRun { + $this->assertActiveContext($tenant, $backupSet); + + if ($restoreRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($restoreRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) { + throw new \RuntimeException('Restore run is already finished.'); + } + + $selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null; + + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + + return $this->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + dryRun: (bool) $restoreRun->is_dry_run, + actorEmail: $actorEmail, + actorName: $actorName, + groupMapping: $restoreRun->group_mapping ?? [], + existingRun: $restoreRun, + ); + } + public function execute( Tenant $tenant, BackupSet $backupSet, @@ -192,6 +231,7 @@ public function execute( ?string $actorEmail = null, ?string $actorName = null, array $groupMapping = [], + ?RestoreRun $existingRun = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); @@ -210,18 +250,46 @@ public function execute( 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()), ]; - $restoreRun = RestoreRun::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'requested_by' => $actorEmail, - 'is_dry_run' => $dryRun, - 'status' => 'running', - 'requested_items' => $selectedItemIds, - 'preview' => $preview, - 'started_at' => CarbonImmutable::now(), - 'metadata' => $wizardMetadata, - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); + if ($existingRun !== null) { + if ($existingRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($existingRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + $metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []); + + $existingRun->update([ + 'requested_by' => $existingRun->requested_by ?? $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'results' => null, + 'failure_reason' => null, + 'started_at' => $existingRun->started_at ?? CarbonImmutable::now(), + 'completed_at' => null, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null), + ]); + + $restoreRun = $existingRun->refresh(); + } else { + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'started_at' => CarbonImmutable::now(), + 'metadata' => $wizardMetadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } if ($groupMapping !== []) { $this->auditLogger->log( diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 338a32a..be82696 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -30,12 +30,12 @@ ## Phase 5 — Preview (Diff) - [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. ## Phase 6 — Confirm & Execute -- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). -- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. -- [ ] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. +- [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). +- [x] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. +- [x] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. ## Phase 7 — Tests + Formatting -- [ ] T018 Add Pest tests for wizard gating rules and status transitions. +- [x] T018 Add Pest tests for wizard gating rules and status transitions. - [x] T019 Add Pest tests for safety checks persistence and blocking behavior. - [x] T020 Add Pest tests for preview summary generation. - [x] T021 Run `./vendor/bin/pint --dirty`. diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php new file mode 100644 index 0000000..9fb258a --- /dev/null +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -0,0 +1,68 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => 'actor@example.com', + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => null, + 'preview' => [], + 'results' => null, + 'metadata' => [], + ]); + + $restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($tenant, $backupSet) { + $mock->shouldReceive('executeForRun') + ->once() + ->withArgs(function (RestoreRun $run, Tenant $runTenant, BackupSet $runBackupSet, ?string $email, ?string $name) use ($tenant, $backupSet): bool { + return $run->status === RestoreRunStatus::Running->value + && $runTenant->is($tenant) + && $runBackupSet->is($backupSet) + && $email === 'actor@example.com' + && $name === 'Actor'; + }) + ->andReturnUsing(function (RestoreRun $run): RestoreRun { + $run->update([ + 'status' => RestoreRunStatus::Completed->value, + 'completed_at' => now(), + ]); + + return $run->refresh(); + }); + }); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $job->handle($restoreService, app(AuditLogger::class)); + + $restoreRun->refresh(); + + expect($restoreRun->started_at)->not->toBeNull(); + expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 7b83c99..de3b9cc 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -165,9 +165,6 @@ ]) ->goToNextWizardStep() ->goToNextWizardStep() - ->fillForm([ - 'is_dry_run' => true, - ]) ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php new file mode 100644 index 0000000..4332917 --- /dev/null +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -0,0 +1,165 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'tester@example.com', + 'name' => 'Tester', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + ]) + ->call('create') + ->assertHasFormErrors(['acknowledged_impact', 'tenant_confirm']); + + expect(RestoreRun::count())->toBe(0); +}); + +test('restore run wizard queues execution when gates are satisfied', function () { + Bus::fake(); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'executor@example.com', + 'name' => 'Executor', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Tenant Two', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->status)->toBe(RestoreRunStatus::Queued->value); + expect($run->is_dry_run)->toBeFalse(); + expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com'); + expect($run->metadata['confirmed_at'] ?? null)->toBeString(); + + Bus::assertDispatched(ExecuteRestoreRunJob::class); +}); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php index 7e8469c..10c9697 100644 --- a/tests/Feature/RestoreRunWizardMetadataTest.php +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -62,9 +62,6 @@ ]) ->goToNextWizardStep() ->goToNextWizardStep() - ->fillForm([ - 'is_dry_run' => true, - ]) ->callFormComponentAction('preview_diffs', 'run_restore_preview') ->goToNextWizardStep() ->call('create') @@ -87,55 +84,3 @@ expect($run->metadata['environment'])->toBe('test'); expect($run->metadata['highlander_label'])->toBe('Tenant One'); }); - -test('restore run wizard always creates dry-run previews in phase 2', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'tenant-2', - 'name' => 'Tenant Two', - 'metadata' => [], - ]); - - $tenant->makeCurrent(); - - $backupSet = BackupSet::create([ - 'tenant_id' => $tenant->id, - 'name' => 'Backup', - 'status' => 'completed', - 'item_count' => 1, - ]); - - BackupItem::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'policy_id' => null, - 'policy_identifier' => 'policy-2', - 'policy_type' => 'deviceConfiguration', - 'platform' => 'windows', - 'payload' => ['id' => 'policy-2'], - 'metadata' => [ - 'displayName' => 'Backup Policy Two', - ], - ]); - - $user = User::factory()->create(); - $this->actingAs($user); - - Livewire::test(CreateRestoreRun::class) - ->fillForm([ - 'backup_set_id' => $backupSet->id, - ]) - ->goToNextWizardStep() - ->goToNextWizardStep() - ->goToNextWizardStep() - ->set('data.is_dry_run', false) - ->callFormComponentAction('preview_diffs', 'run_restore_preview') - ->goToNextWizardStep() - ->call('create') - ->assertHasNoFormErrors(); - - $run = RestoreRun::query()->latest('id')->first(); - - expect($run)->not->toBeNull(); - expect($run->is_dry_run)->toBeTrue(); - expect($run->status)->toBe('previewed'); -});