merge: agent session work
This commit is contained in:
commit
948af56185
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
134
app/Jobs/ExecuteRestoreRunJob.php
Normal file
134
app/Jobs/ExecuteRestoreRunJob.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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`.
|
||||
|
||||
68
tests/Feature/ExecuteRestoreRunJobTest.php
Normal file
68
tests/Feature/ExecuteRestoreRunJobTest.php
Normal 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);
|
||||
});
|
||||
@ -165,9 +165,6 @@
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->goToNextWizardStep()
|
||||
->fillForm([
|
||||
'is_dry_run' => true,
|
||||
])
|
||||
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
|
||||
165
tests/Feature/RestoreRunWizardExecuteTest.php
Normal file
165
tests/Feature/RestoreRunWizardExecuteTest.php
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user