merge: agent session work

This commit is contained in:
Ahmed Darrazi 2025-12-31 01:51:03 +01:00
commit 948af56185
9 changed files with 669 additions and 108 deletions

View File

@ -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();
}

View File

@ -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

View File

@ -0,0 +1,134 @@
<?php
namespace App\Jobs;
use App\Models\RestoreRun;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ExecuteRestoreRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $restoreRunId,
public ?string $actorEmail = null,
public ?string $actorName = null,
) {}
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
{
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->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;
}
}
}

View File

@ -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,6 +250,33 @@ public function execute(
'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
];
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,
@ -222,6 +289,7 @@ public function execute(
'metadata' => $wizardMetadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
}
if ($groupMapping !== []) {
$this->auditLogger->log(

View File

@ -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`.

View File

@ -0,0 +1,68 @@
<?php
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
test('execute restore run job moves queued to running and calls the executor', function () {
$tenant = Tenant::create([
'tenant_id' => '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);
});

View File

@ -165,9 +165,6 @@
])
->goToNextWizardStep()
->goToNextWizardStep()
->fillForm([
'is_dry_run' => true,
])
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->call('create')

View File

@ -0,0 +1,165 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore run wizard blocks execution when confirmations are missing', function () {
$tenant = Tenant::create([
'tenant_id' => '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);
});

View File

@ -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');
});