feat/049-backup-restore-job-orchestration #56

Merged
ahmido merged 11 commits from feat/049-backup-restore-job-orchestration into dev 2026-01-11 15:59:06 +00:00
51 changed files with 2340 additions and 186 deletions
Showing only changes of commit bf37c3e76d - Show all commits

View File

@ -0,0 +1,138 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class BulkOperationRunResource extends Resource
{
protected static bool $isScopedToTenant = false;
protected static ?string $model = BulkOperationRun::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function form(Schema $schema): Schema
{
return $schema;
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Section::make('Run')
->schema([
TextEntry::make('user.name')
->label('Initiator')
->placeholder('—'),
TextEntry::make('resource')->badge(),
TextEntry::make('action')->badge(),
TextEntry::make('status')
->badge()
->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)),
TextEntry::make('total_items')->label('Total')->numeric(),
TextEntry::make('processed_items')->label('Processed')->numeric(),
TextEntry::make('succeeded')->numeric(),
TextEntry::make('failed')->numeric(),
TextEntry::make('skipped')->numeric(),
TextEntry::make('created_at')->dateTime(),
TextEntry::make('updated_at')->dateTime(),
TextEntry::make('idempotency_key')->label('Idempotency key')->copyable()->placeholder('—'),
])
->columns(2)
->columnSpanFull(),
Section::make('Items')
->schema([
ViewEntry::make('item_ids')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (BulkOperationRun $record) => $record->item_ids ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Failures')
->schema([
ViewEntry::make('failures')
->label('')
->view('filament.infolists.entries.snapshot-json')
->state(fn (BulkOperationRun $record) => $record->failures ?? [])
->columnSpanFull(),
])
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()->getKey();
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
})
->columns([
Tables\Columns\TextColumn::make('user.name')
->label('Initiator')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('resource')->badge(),
Tables\Columns\TextColumn::make('action')->badge(),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (BulkOperationRun $record): string => static::statusColor($record->status)),
Tables\Columns\TextColumn::make('created_at')->since(),
Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(),
Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(),
Tables\Columns\TextColumn::make('failed')->numeric(),
])
->actions([
Actions\ViewAction::make(),
])
->bulkActions([]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with('user')
->latest('id');
}
public static function getPages(): array
{
return [
'index' => Pages\ListBulkOperationRuns::route('/'),
'view' => Pages\ViewBulkOperationRun::route('/{record}'),
];
}
private static function statusColor(?string $status): string
{
return match ($status) {
'completed' => 'success',
'completed_with_errors' => 'warning',
'failed' => 'danger',
'running' => 'info',
default => 'gray',
};
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use Filament\Resources\Pages\ListRecords;
class ListBulkOperationRuns extends ListRecords
{
protected static string $resource = BulkOperationRunResource::class;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBulkOperationRun extends ViewRecord
{
protected static string $resource = BulkOperationRunResource::class;
}

View File

@ -2,13 +2,17 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\BulkOperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Services\Intune\VersionService;
use App\Jobs\CapturePolicySnapshotJob;
use App\Services\BulkOperationService;
use App\Support\RunIdempotency;
use Filament\Actions\Action;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width;
use Illuminate\Support\Str;
class ViewPolicy extends ViewRecord
{
@ -23,7 +27,7 @@ protected function getActions(): array
->label('Capture snapshot')
->requiresConfirmation()
->modalHeading('Capture snapshot now')
->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.')
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
->form([
Forms\Components\Checkbox::make('include_assignments')
->label('Include assignments')
@ -37,51 +41,79 @@ protected function getActions(): array
->action(function (array $data) {
$policy = $this->record;
try {
$tenant = $policy->tenant;
$tenant = $policy->tenant;
$user = auth()->user();
if (! $tenant) {
Notification::make()
->title('Policy has no tenant associated.')
->danger()
->send();
return;
}
$version = app(VersionService::class)->captureFromGraph(
tenant: $tenant,
policy: $policy,
createdBy: auth()->user()?->email ?? null,
includeAssignments: $data['include_assignments'] ?? false,
includeScopeTags: $data['include_scope_tags'] ?? false,
);
if (($version->metadata['source'] ?? null) === 'metadata_only') {
$status = $version->metadata['original_status'] ?? null;
Notification::make()
->title('Snapshot captured (metadata only)')
->body(sprintf(
'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.',
$status ?? 'an error'
))
->warning()
->send();
} else {
Notification::make()
->title('Snapshot captured successfully.')
->success()
->send();
}
$this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()]));
} catch (\Throwable $e) {
if (! $tenant || ! $user) {
Notification::make()
->title('Failed to capture snapshot: '.$e->getMessage())
->title('Missing tenant or user context.')
->danger()
->send();
return;
}
$idempotencyKey = RunIdempotency::buildKey(
tenantId: $tenant->getKey(),
operationType: 'policy.capture_snapshot',
targetId: $policy->getKey()
);
$existingRun = RunIdempotency::findActiveBulkOperationRun(
tenantId: $tenant->getKey(),
idempotencyKey: $idempotencyKey
);
if ($existingRun) {
Notification::make()
->title('Snapshot already in progress')
->body('An active run already exists for this policy. Opening run details.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
])
->info()
->send();
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant));
return;
}
$bulkOperationService = app(BulkOperationService::class);
$run = $bulkOperationService->createRun(
tenant: $tenant,
user: $user,
resource: 'policies',
action: 'capture_snapshot',
itemIds: [(string) $policy->getKey()],
totalItems: 1
);
$run->update(['idempotency_key' => $idempotencyKey]);
CapturePolicySnapshotJob::dispatch(
bulkOperationRunId: $run->getKey(),
policyId: $policy->getKey(),
includeAssignments: (bool) ($data['include_assignments'] ?? false),
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
createdBy: $user->email ? Str::limit($user->email, 255, '') : null
);
Notification::make()
->title('Snapshot queued')
->body('A background job has been queued. You can monitor progress in the run details.')
->actions([
\Filament\Actions\Action::make('view_run')
->label('View run')
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
])
->success()
->send();
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant));
})
->color('primary'),
];

View File

@ -11,12 +11,14 @@
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Rules\SkipOrUuidRule;
use App\Services\BulkOperationService;
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 App\Support\RunIdempotency;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
@ -36,6 +38,7 @@
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@ -115,31 +118,7 @@ public static function form(Schema $schema): Schema
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
->label($label)
->placeholder('SKIP or target group Object ID (GUID)')
->rules([
static function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value)) {
$fail('Please enter SKIP or a valid UUID.');
return;
}
$value = trim($value);
if ($value === '') {
$fail('Please enter SKIP or a valid UUID.');
return;
}
if (strtoupper($value) === 'SKIP') {
return;
}
if (! Str::isUuid($value)) {
$fail('Please enter SKIP or a valid UUID.');
}
},
])
->rules([new SkipOrUuidRule])
->required()
->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.');
}, $unresolved);
@ -345,31 +324,7 @@ public static function getWizardSteps(): array
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
->label($label)
->placeholder('SKIP or target group Object ID (GUID)')
->rules([
static function (string $attribute, mixed $value, \Closure $fail): void {
if (! is_string($value)) {
$fail('Please enter SKIP or a valid UUID.');
return;
}
$value = trim($value);
if ($value === '') {
$fail('Please enter SKIP or a valid UUID.');
return;
}
if (strtoupper($value) === 'SKIP') {
return;
}
if (! Str::isUuid($value)) {
$fail('Please enter SKIP or a valid UUID.');
}
},
])
->rules([new SkipOrUuidRule])
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('check_summary', null);
@ -678,6 +633,15 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
Tables\Columns\TextColumn::make('status')->badge(),
Tables\Columns\TextColumn::make('summary_total')
->label('Total')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
Tables\Columns\TextColumn::make('summary_succeeded')
->label('Succeeded')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
Tables\Columns\TextColumn::make('summary_failed')
->label('Failed')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
Tables\Columns\TextColumn::make('started_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
@ -722,6 +686,116 @@ public static function table(Table $table): Table
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->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,
'rerun_of_restore_run_id' => $record->id,
];
$idempotencyKey = RunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $newRun->id,
'backup_set_id' => $backupSet->id,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
);
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName);
$auditLogger->log(
tenant: $tenant,
action: 'restore_run.rerun',
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
context: [
'metadata' => [
'original_restore_run_id' => $record->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
Notification::make()
->title('Restore run queued')
->success()
->send();
return;
}
try {
$newRun = $restoreService->execute(
tenant: $tenant,
@ -1008,6 +1082,16 @@ public static function infolist(Schema $schema): Schema
->schema([
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
Infolists\Components\TextEntry::make('status')->badge(),
Infolists\Components\TextEntry::make('counts')
->label('Counts')
->state(function (RestoreRun $record): string {
$meta = $record->metadata ?? [];
$total = (int) ($meta['total'] ?? 0);
$succeeded = (int) ($meta['succeeded'] ?? 0);
$failed = (int) ($meta['failed'] ?? 0);
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed);
}),
Infolists\Components\TextEntry::make('is_dry_run')
->label('Dry-run')
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
@ -1327,17 +1411,53 @@ public static function createRestoreRun(array $data): RestoreRun
$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,
]);
$idempotencyKey = RunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return $existing;
}
try {
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
Notification::make()
->title('Restore already queued')
->body('Reusing the active restore run.')
->info()
->send();
return $existing;
}
throw $exception;
}
app(AuditLogger::class)->log(
tenant: $tenant,

View File

@ -0,0 +1,107 @@
<?php
namespace App\Jobs;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Intune\VersionService;
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 CapturePolicySnapshotJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public int $bulkOperationRunId,
public int $policyId,
public bool $includeAssignments = true,
public bool $includeScopeTags = true,
public ?string $createdBy = null,
) {}
public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void
{
$run = BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkOperationRunId);
if (! $run) {
return;
}
$policy = Policy::query()->with('tenant')->find($this->policyId);
if (! $policy || ! $policy->tenant) {
$bulkOperationService->abort($run, 'policy_not_found');
$this->notifyStatus($run, 'failed');
return;
}
$this->notifyStatus($run, 'queued');
$bulkOperationService->start($run);
$this->notifyStatus($run, 'running');
try {
$versionService->captureFromGraph(
tenant: $policy->tenant,
policy: $policy,
createdBy: $this->createdBy,
includeAssignments: $this->includeAssignments,
includeScopeTags: $this->includeScopeTags,
);
$bulkOperationService->recordSuccess($run);
$bulkOperationService->complete($run);
$this->notifyStatus($run, $run->refresh()->status);
} catch (Throwable $e) {
$bulkOperationService->recordFailure(
run: $run,
itemId: (string) $policy->getKey(),
reason: $bulkOperationService->sanitizeFailureReason($e->getMessage())
);
$bulkOperationService->complete($run);
$this->notifyStatus($run->refresh(), $run->status);
throw $e;
}
}
private function notifyStatus(BulkOperationRun $run, string $status): void
{
if (! $run->relationLoaded('user')) {
$run->loadMissing('user');
}
if (! $run->user) {
return;
}
$normalizedStatus = $status === 'pending' ? 'queued' : $status;
$run->user->notify(new RunStatusChangedNotification([
'tenant_id' => (int) $run->tenant_id,
'run_type' => 'bulk_operation',
'run_id' => (int) $run->getKey(),
'status' => (string) $normalizedStatus,
'counts' => [
'total' => (int) $run->total_items,
'processed' => (int) $run->processed_items,
'succeeded' => (int) $run->succeeded,
'failed' => (int) $run->failed,
'skipped' => (int) $run->skipped,
],
]));
}
}

View File

@ -3,6 +3,9 @@
namespace App\Jobs;
use App\Models\RestoreRun;
use App\Models\User;
use App\Notifications\RunStatusChangedNotification;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
@ -24,7 +27,7 @@ public function __construct(
public ?string $actorName = null,
) {}
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
public function handle(RestoreService $restoreService, AuditLogger $auditLogger, BulkOperationService $bulkOperationService): void
{
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
@ -36,6 +39,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
return;
}
$this->notifyStatus($restoreRun, 'queued');
$tenant = $restoreRun->tenant;
$backupSet = $restoreRun->backupSet;
@ -46,6 +51,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
'completed_at' => CarbonImmutable::now(),
]);
$this->notifyStatus($restoreRun->refresh(), 'failed');
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
@ -74,6 +81,8 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
'failure_reason' => null,
]);
$this->notifyStatus($restoreRun->refresh(), 'running');
$auditLogger->log(
tenant: $tenant,
action: 'restore.started',
@ -98,17 +107,23 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
actorEmail: $this->actorEmail,
actorName: $this->actorName,
);
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
} catch (Throwable $throwable) {
$restoreRun->refresh();
$safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage());
if ($restoreRun->status === RestoreRunStatus::Running->value) {
$restoreRun->update([
'status' => RestoreRunStatus::Failed->value,
'failure_reason' => $throwable->getMessage(),
'failure_reason' => $safeReason,
'completed_at' => CarbonImmutable::now(),
]);
}
$this->notifyStatus($restoreRun->refresh(), (string) $restoreRun->status);
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
@ -117,7 +132,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'reason' => $throwable->getMessage(),
'reason' => $safeReason,
],
],
actorEmail: $this->actorEmail,
@ -131,4 +146,45 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
throw $throwable;
}
}
private function notifyStatus(RestoreRun $restoreRun, string $status): void
{
$email = $this->actorEmail;
if (! is_string($email) || $email === '') {
$email = is_string($restoreRun->requested_by) ? $restoreRun->requested_by : null;
}
if (! is_string($email) || $email === '') {
return;
}
$user = User::query()->where('email', $email)->first();
if (! $user) {
return;
}
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
$counts = [];
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
$counts[$key] = (int) $metadata[$key];
}
}
$payload = [
'tenant_id' => (int) $restoreRun->tenant_id,
'run_type' => 'restore',
'run_id' => (int) $restoreRun->getKey(),
'status' => $status,
];
if ($counts !== []) {
$payload['counts'] = $counts;
}
$user->notify(new RunStatusChangedNotification($payload));
}
}

View File

@ -15,6 +15,7 @@ class BulkOperationRun extends Model
'user_id',
'resource',
'action',
'idempotency_key',
'status',
'total_items',
'processed_items',

View File

@ -18,6 +18,7 @@ class RestoreRun extends Model
protected $casts = [
'is_dry_run' => 'boolean',
'idempotency_key' => 'string',
'requested_items' => 'array',
'preview' => 'array',
'results' => 'array',
@ -104,6 +105,15 @@ public function getAssignmentRestoreOutcomes(): array
return $results['assignment_outcomes'];
}
if (isset($results['items']) && is_array($results['items'])) {
return collect($results['items'])
->pluck('assignment_outcomes')
->flatten(1)
->filter()
->values()
->all();
}
if (! is_array($results)) {
return [];
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Notifications;
use App\Filament\Resources\BulkOperationRunResource;
use App\Filament\Resources\RestoreRunResource;
use App\Models\Tenant;
use Filament\Actions\Action;
use Illuminate\Notifications\Notification;
class RunStatusChangedNotification extends Notification
{
/**
* @param array{
* tenant_id:int,
* run_type:string,
* run_id:int,
* status:string,
* counts?:array{total?:int, processed?:int, succeeded?:int, failed?:int, skipped?:int}
* } $metadata
*/
public function __construct(public array $metadata) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
$status = (string) ($this->metadata['status'] ?? 'queued');
$runType = (string) ($this->metadata['run_type'] ?? 'run');
$tenantId = (int) ($this->metadata['tenant_id'] ?? 0);
$runId = (int) ($this->metadata['run_id'] ?? 0);
$title = match ($status) {
'queued' => 'Run queued',
'running' => 'Run started',
'completed', 'succeeded' => 'Run completed',
'partial', 'completed_with_errors' => 'Run completed (partial)',
'failed' => 'Run failed',
default => 'Run updated',
};
$body = sprintf('A %s run changed status to: %s.', str_replace('_', ' ', $runType), $status);
$color = match ($status) {
'queued', 'running' => 'gray',
'completed', 'succeeded' => 'success',
'partial', 'completed_with_errors' => 'warning',
'failed' => 'danger',
default => 'gray',
};
$actions = [];
if (in_array($runType, ['bulk_operation', 'restore'], true) && $tenantId > 0 && $runId > 0) {
$tenant = Tenant::query()->find($tenantId);
if ($tenant) {
$url = $runType === 'bulk_operation'
? BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
: RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant);
$actions[] = Action::make('view_run')
->label('View run')
->url($url)
->toArray();
}
}
return [
'format' => 'filament',
'title' => $title,
'body' => $body,
'color' => $color,
'duration' => 'persistent',
'actions' => $actions,
'icon' => null,
'iconColor' => null,
'status' => null,
'view' => null,
'viewData' => [
'metadata' => $this->metadata,
],
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class BulkOperationRunPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
return $user->canAccessTenant($tenant);
}
public function view(User $user, BulkOperationRun $run): bool
{
$tenant = Tenant::current();
if (! $tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
return false;
}
return (int) $run->tenant_id === (int) $tenant->getKey();
}
}

View File

@ -107,5 +107,6 @@ public function boot(): void
});
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Str;
class SkipOrUuidRule implements ValidationRule
{
public function __construct(public bool $allowSkip = true) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail('Please enter SKIP or a valid UUID.');
return;
}
$value = trim($value);
if ($value === '') {
$fail('Please enter SKIP or a valid UUID.');
return;
}
if ($this->allowSkip && strtoupper($value) === 'SKIP') {
return;
}
if (! Str::isUuid($value)) {
$fail('Please enter SKIP or a valid UUID.');
}
}
}

View File

@ -13,6 +13,30 @@ public function __construct(
protected AuditLogger $auditLogger
) {}
public function sanitizeFailureReason(string $reason): string
{
$reason = trim($reason);
if ($reason === '') {
return 'error';
}
$lower = mb_strtolower($reason);
if (
str_contains($lower, 'bearer ') ||
str_contains($lower, 'access_token') ||
str_contains($lower, 'client_secret') ||
str_contains($lower, 'authorization')
) {
return 'redacted';
}
$reason = preg_replace("/\s+/u", ' ', $reason) ?? $reason;
return mb_substr($reason, 0, 200);
}
public function createRun(
Tenant $tenant,
User $user,
@ -70,6 +94,8 @@ public function recordSuccess(BulkOperationRun $run): void
public function recordFailure(BulkOperationRun $run, string $itemId, string $reason): void
{
$reason = $this->sanitizeFailureReason($reason);
$failures = $run->failures ?? [];
$failures[] = [
'item_id' => $itemId,
@ -92,6 +118,8 @@ public function recordSkipped(BulkOperationRun $run): void
public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason): void
{
$reason = $this->sanitizeFailureReason($reason);
$failures = $run->failures ?? [];
$failures[] = [
'item_id' => $itemId,
@ -164,6 +192,8 @@ public function fail(BulkOperationRun $run, string $reason): void
{
$run->update(['status' => 'failed']);
$reason = $this->sanitizeFailureReason($reason);
$this->auditLogger->log(
tenant: $run->tenant,
action: "bulk.{$run->resource}.{$run->action}.failed",
@ -184,6 +214,8 @@ public function abort(BulkOperationRun $run, string $reason): void
{
$run->update(['status' => 'aborted']);
$reason = $this->sanitizeFailureReason($reason);
$this->auditLogger->log(
tenant: $run->tenant,
action: "bulk.{$run->resource}.{$run->action}.aborted",

View File

@ -327,6 +327,26 @@ public function execute(
$foundationEntries = $foundationOutcome['entries'] ?? [];
$foundationFailures = (int) ($foundationOutcome['failed'] ?? 0);
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
$foundationBackupItemIdsBySourceId = $foundationItems
->pluck('id', 'policy_identifier')
->map(fn ($id) => is_int($id) ? $id : null)
->filter()
->all();
$foundationEntries = array_values(array_map(function (mixed $entry) use ($foundationBackupItemIdsBySourceId): mixed {
if (! is_array($entry)) {
return $entry;
}
$sourceId = $entry['sourceId'] ?? null;
if (is_string($sourceId) && isset($foundationBackupItemIdsBySourceId[$sourceId])) {
$entry['backup_item_id'] = (int) $foundationBackupItemIdsBySourceId[$sourceId];
}
return $entry;
}, $foundationEntries));
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
$scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries);
@ -866,14 +886,62 @@ public function execute(
default => 'completed',
});
$isFoundationEntry = static function (mixed $item): bool {
return is_array($item)
&& array_key_exists('decision', $item)
&& array_key_exists('sourceId', $item);
};
$foundationResults = collect($results)->filter($isFoundationEntry)->values();
$policyResults = collect($results)->reject($isFoundationEntry)->values();
$itemsByBackupItemId = $policyResults
->filter(fn (mixed $item): bool => is_array($item) && isset($item['backup_item_id']) && is_numeric($item['backup_item_id']))
->keyBy(fn (array $item): string => (string) $item['backup_item_id'])
->all();
$policyStatusCounts = collect($itemsByBackupItemId)
->map(fn (array $item): string => (string) ($item['status'] ?? 'unknown'))
->countBy();
$foundationDecisionCounts = $foundationResults
->map(fn (array $entry): string => (string) ($entry['decision'] ?? 'unknown'))
->countBy();
$policyTotal = count($itemsByBackupItemId);
$foundationTotal = $foundationResults->count();
$total = $policyTotal + $foundationTotal;
$succeeded = (int) ($policyStatusCounts['applied'] ?? 0)
+ $foundationDecisionCounts
->except(['failed', 'skipped'])
->sum();
$failed = (int) ($policyStatusCounts['failed'] ?? 0)
+ (int) ($foundationDecisionCounts['failed'] ?? 0);
$skipped = (int) ($policyStatusCounts['skipped'] ?? 0)
+ (int) ($foundationDecisionCounts['skipped'] ?? 0);
$partialCount = (int) ($policyStatusCounts['partial'] ?? 0)
+ (int) ($policyStatusCounts['manual_required'] ?? 0);
$persistedResults = [
'foundations' => $foundationResults->all(),
'items' => $itemsByBackupItemId,
];
$restoreRun->update([
'status' => $status,
'results' => $results,
'results' => $persistedResults,
'completed_at' => CarbonImmutable::now(),
'metadata' => array_merge($restoreRun->metadata ?? [], [
'failed' => $hardFailures,
'total' => $total,
'succeeded' => $succeeded,
'failed' => $failed,
'skipped' => $skipped,
'partial' => $partialCount,
'non_applied' => $nonApplied,
'total' => $totalCount,
'foundations_skipped' => $foundationSkipped,
]),
]);

View File

@ -0,0 +1,95 @@
<?php
namespace App\Support;
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use Illuminate\Support\Arr;
final class RunIdempotency
{
/**
* @param array<string, mixed> $context
*/
public static function buildKey(int $tenantId, string $operationType, string|int|null $targetId = null, array $context = []): string
{
$payload = [
'tenant_id' => $tenantId,
'operation_type' => trim($operationType),
'target_id' => $targetId === null ? null : (string) $targetId,
'context' => self::canonicalize($context),
];
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
}
public static function findActiveBulkOperationRun(int $tenantId, string $idempotencyKey): ?BulkOperationRun
{
return BulkOperationRun::query()
->where('tenant_id', $tenantId)
->where('idempotency_key', $idempotencyKey)
->whereIn('status', ['pending', 'running'])
->latest('id')
->first();
}
public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun
{
return RestoreRun::query()
->where('tenant_id', $tenantId)
->where('idempotency_key', $idempotencyKey)
->whereIn('status', ['queued', 'running'])
->latest('id')
->first();
}
/**
* Deterministic idempotency key for a live restore execution.
*
* @param array<int>|null $selectedItemIds
* @param array<string, string> $groupMapping
*/
public static function restoreExecuteKey(
int $tenantId,
int $backupSetId,
?array $selectedItemIds,
array $groupMapping = [],
): string {
$scopeIds = $selectedItemIds;
if (is_array($scopeIds)) {
$scopeIds = array_values(array_unique(array_map('intval', $scopeIds)));
sort($scopeIds);
}
return self::buildKey(
tenantId: $tenantId,
operationType: 'restore.execute',
targetId: (string) $backupSetId,
context: [
'scope' => $scopeIds,
'group_mapping' => $groupMapping,
],
);
}
/**
* @param array<string, mixed> $value
* @return array<string, mixed>
*/
private static function canonicalize(array $value): array
{
$value = Arr::map($value, function (mixed $item): mixed {
if (is_array($item)) {
/** @var array<string, mixed> $item */
return static::canonicalize($item);
}
return $item;
});
ksort($value);
return $value;
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('bulk_operation_runs', function (Blueprint $table) {
$table->string('idempotency_key', 64)->nullable()->after('action');
});
Schema::table('bulk_operation_runs', function (Blueprint $table) {
$table->index(['tenant_id', 'idempotency_key'], 'bulk_runs_tenant_idempotency');
});
DB::statement("CREATE UNIQUE INDEX bulk_runs_idempotency_active ON bulk_operation_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')");
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS bulk_runs_idempotency_active');
Schema::table('bulk_operation_runs', function (Blueprint $table) {
$table->dropIndex('bulk_runs_tenant_idempotency');
$table->dropColumn('idempotency_key');
});
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('restore_runs', function (Blueprint $table) {
$table->string('idempotency_key', 64)->nullable()->after('status');
});
Schema::table('restore_runs', function (Blueprint $table) {
$table->index(['tenant_id', 'idempotency_key'], 'restore_runs_tenant_idempotency');
});
DB::statement("CREATE UNIQUE INDEX restore_runs_idempotency_active ON restore_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('queued', 'running')");
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS restore_runs_idempotency_active');
Schema::table('restore_runs', function (Blueprint $table) {
$table->dropIndex('restore_runs_tenant_idempotency');
$table->dropColumn('idempotency_key');
});
}
};

View File

@ -1,14 +1,21 @@
@php
$results = $getState() ?? [];
$foundationItems = collect($results)->filter(function ($item) {
$state = $getState() ?? [];
$isFoundationEntry = function ($item) {
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
});
$policyItems = collect($results)->reject(function ($item) {
return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item);
});
};
if (is_array($state) && array_key_exists('items', $state)) {
$foundationItems = collect($state['foundations'] ?? [])->filter($isFoundationEntry);
$policyItems = collect($state['items'] ?? [])->values();
$results = $state;
} else {
$results = $state;
$foundationItems = collect($results)->filter($isFoundationEntry);
$policyItems = collect($results)->reject($isFoundationEntry);
}
@endphp
@if (empty($results))
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
<p class="text-sm text-gray-600">No results recorded.</p>
@else
@php

View File

@ -0,0 +1,169 @@
openapi: 3.0.3
info:
title: TenantPilot Admin Run Orchestration (049)
version: 0.1.0
description: |
Internal admin contracts for starting long-running backup/restore operations
and reading run status/progress. These endpoints are tenant-scoped.
servers:
- url: /admin
paths:
/t/{tenantExternalId}/runs/{runType}:
post:
operationId: startRun
summary: Start a background run
description: |
Starts an operation by creating (or reusing) a Run Record and enqueueing
background work. Must return quickly.
parameters:
- in: path
name: tenantExternalId
required: true
schema:
type: string
- in: path
name: runType
required: true
schema:
type: string
enum:
- backup_set_add_policies
- restore_execute
- restore_preview
- snapshot_capture
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/RunStartRequest'
responses:
'201':
description: Run created and queued
content:
application/json:
schema:
$ref: '#/components/schemas/RunStartResponse'
'200':
description: Existing active run reused
content:
application/json:
schema:
$ref: '#/components/schemas/RunStartResponse'
'403':
description: Forbidden
/t/{tenantExternalId}/runs/{runType}/{runId}:
get:
operationId: getRun
summary: Get run status and progress
parameters:
- in: path
name: tenantExternalId
required: true
schema:
type: string
- in: path
name: runType
required: true
schema:
type: string
- in: path
name: runId
required: true
schema:
type: string
responses:
'200':
description: Run record
content:
application/json:
schema:
$ref: '#/components/schemas/RunRecord'
'404':
description: Not found
components:
schemas:
RunStartRequest:
type: object
additionalProperties: false
properties:
targetObjectId:
type: string
nullable: true
description: Operation target used for de-duplication.
payloadHash:
type: string
nullable: true
description: Optional stable hash of relevant payload to strengthen idempotency.
itemIds:
type: array
items:
type: string
nullable: true
description: Optional internal item ids to process.
RunStartResponse:
type: object
required: [run]
properties:
reused:
type: boolean
default: false
run:
$ref: '#/components/schemas/RunRecord'
RunRecord:
type: object
required:
- id
- tenantExternalId
- type
- status
properties:
id:
type: string
tenantExternalId:
type: string
type:
type: string
status:
type: string
enum: [queued, running, succeeded, failed, partial]
createdAt:
type: string
format: date-time
startedAt:
type: string
format: date-time
nullable: true
finishedAt:
type: string
format: date-time
nullable: true
counts:
type: object
additionalProperties: false
properties:
total:
type: integer
minimum: 0
succeeded:
type: integer
minimum: 0
failed:
type: integer
minimum: 0
safeError:
type: object
nullable: true
additionalProperties: false
properties:
code:
type: string
context:
type: object
additionalProperties: true

View File

@ -0,0 +1,94 @@
# Data Model: Backup/Restore Job Orchestration (049)
This feature relies on existing “run record” models/tables and (optionally) extends them to meet the orchestration requirements.
## Entities
## 1) RestoreRun (`restore_runs`)
**Purpose:** Run record for restore executions and dry-run/preview workflows.
**Model:** `App\Models\RestoreRun`
**Key fields (existing):**
- `id` (PK)
- `tenant_id` (FK → tenants)
- `backup_set_id` (FK → backup_sets)
- `requested_by` (string|null)
- `is_dry_run` (bool)
- `status` (string)
- `requested_items` (json|null)
- `preview` (json|null) — persisted preview output
- `results` (json|null) — persisted execution output (may include per-item outcomes)
- `failure_reason` (text|null)
- `started_at` / `completed_at` (timestamp|null)
- `metadata` (json|null)
**Relationships:**
- `RestoreRun belongsTo Tenant`
- `RestoreRun belongsTo BackupSet`
**State transitions (target):**
- `queued → running → succeeded|failed|partial`
**Validation constraints (creation/dispatch):**
- tenant-scoped access required
- `backup_set_id` must belong to tenant
- preview/dry-run must not perform writes (constitution Read/Write Separation)
---
## 2) BulkOperationRun (`bulk_operation_runs`)
**Purpose:** Run record for background operations that process many internal items, including backup-set capture-like actions.
**Model:** `App\Models\BulkOperationRun`
**Key fields (existing):**
- `id` (PK)
- `tenant_id` (FK → tenants)
- `user_id` (FK → users)
- `resource` (string) — e.g. `policy`, `backup_set`
- `action` (string) — e.g. `export`, `add_policies`
- `status` (string) — `pending`, `running`, `completed`, `completed_with_errors`, `failed`, `aborted`
- `total_items`, `processed_items`, `succeeded`, `failed`, `skipped`
- `item_ids` (jsonb)
- `failures` (jsonb|null) — safe per-item error summaries
- `audit_log_id` (FK → audit_logs|null)
**Relationships:**
- `BulkOperationRun belongsTo Tenant`
- `BulkOperationRun belongsTo User`
**Recommended additions (to satisfy FR-002/FR-004 cleanly):**
- `idempotency_key` (string, indexed; uniqueness enforced for active statuses via partial index)
- `started_at` / `finished_at` (timestampTz)
- `error_code` (string|null)
- `error_context` (jsonb|null)
**State transitions (target):**
- `queued → running → succeeded|failed|partial`
- `pending` maps to `queued`
- `completed_with_errors` maps to `partial`
---
## 3) Notification Event (DB notifications)
**Purpose:** Persist state transitions and completion notices for the initiating user.
**Storage:** Laravel Notifications (DB channel).
**Payload shape (target):**
- `tenant_id`
- `run_type` (restore_run / bulk_operation_run)
- `run_id`
- `status` (queued/running/succeeded/failed/partial)
- `counts` (optional)
- `safe_error_code` + `safe_error_context` (optional)
## Notes on “per-item outcomes” (FR-005)
- For restore workflows, per-item outcomes can initially be stored in `restore_runs.results` as a structured JSON array/object keyed by internal item identifiers.
- For bulk operations, per-item outcomes are already persisted as `bulk_operation_runs.failures` plus the counter columns.
- If Phase 1 needs relational per-item tables for querying/filtering, introduce a dedicated “run item results” table per run type (Phase 2+ preferred).

View File

@ -0,0 +1,102 @@
# Implementation Plan: Backup/Restore Job Orchestration (049)
**Branch**: `feat/049-backup-restore-job-orchestration-session-1768091854` | **Date**: 2026-01-11 | **Spec**: [specs/049-backup-restore-job-orchestration/spec.md](specs/049-backup-restore-job-orchestration/spec.md)
**Input**: Feature specification from `specs/049-backup-restore-job-orchestration/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Move all backup/restore “start/execute” actions off the interactive request path.
- Interactive actions must only create (or reuse) a tenant-scoped Run Record and enqueue work.
- Background jobs perform Graph calls, capture/restore work, and update run records with status + counts + safe error summaries.
- Idempotency prevents double-click duplicates by reusing an active run for the same `(tenant + operation type + target)`.
Design choices are captured in [specs/049-backup-restore-job-orchestration/research.md](specs/049-backup-restore-job-orchestration/research.md).
## Phasing
### Phase 1 (this specs implementation target)
- Ensure all in-scope operations are job-only (no heavy work inline).
- Create/reuse run records with idempotency for active runs.
- Provide **Run detail** views for progress (status + counts) and **DB notifications** for state transitions.
### Phase 2 (explicitly out-of-scope for Phase 1)
- Add a **global progress widget** that surfaces all run types (not just bulk ops) across the admin UI.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament 4, Livewire 3
**Storage**: PostgreSQL (JSONB used for run payloads/summaries where appropriate)
**Testing**: Pest 4 (feature tests + job tests)
**Target Platform**: Containerized web app (Sail for local dev; Dokploy for staging/prod)
**Project Type**: Web application (Laravel monolith)
**Performance Goals**: 95% of start actions confirm “queued” within 2 seconds (SC-001)
**Constraints**: No heavy work during interactive requests; jobs must be idempotent + observable; no secrets in run records
**Scale/Scope**: Multi-tenant MSP usage; long-running Graph operations; frequent retries/double-click scenarios
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: orchestration is run-record centric; inventory stays “last observed”, backups remain explicit actions.
- Read/write separation: preview/dry-run stays read-only; live restore remains behind explicit confirmation + audit + tests.
- Graph contract path: all Graph calls remain behind `GraphClientInterface` and contract registry (`config/graph_contracts.php`).
- Deterministic capabilities: no new capability derivation introduced by this feature (existing resolver remains authoritative).
- Tenant isolation: all run visibility + execution is tenant-scoped; no cross-tenant run access.
- Automation: enforce de-duplication for active runs; jobs use locks/backoff for 429/503 where applicable.
- Data minimization: run records store only safe summaries (error codes + whitelisted context), never secrets/tokens.
## Project Structure
### Documentation (this feature)
```text
specs/049-backup-restore-job-orchestration/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Resources/
├── Jobs/
├── Livewire/
├── Models/
├── Services/
└── Support/
database/
└── migrations/
resources/
└── views/
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Laravel monolith; orchestration implemented via queued jobs + run records in existing models/tables.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
No constitution violations are required for this feature.

View File

@ -0,0 +1,26 @@
# Quickstart: Backup/Restore Job Orchestration (049)
## Goal
Ensure backup/restore “start/execute” actions never run heavy work inline. They create (or reuse) a Run Record and queue the work.
## Local development
- Bring Sail up: `./vendor/bin/sail up -d`
- Run migrations: `./vendor/bin/sail artisan migrate`
- Run a queue worker (separate terminal): `./vendor/bin/sail artisan queue:work`
## Testing
Run the most relevant tests first:
- Unit helpers: `./vendor/bin/sail artisan test tests/Unit/RunIdempotencyTest.php`
- Snapshot capture orchestration: `./vendor/bin/sail artisan test --filter=PolicyCaptureSnapshot`
- Restore orchestration: `./vendor/bin/sail artisan test --filter=RestoreRun`
- Cross-tenant authorization: `./vendor/bin/sail artisan test --filter=RunAuthorization`
## Operational notes
- Run records must be tenant-scoped and never contain secrets.
- Preview/dry-run must remain read-only.
- Use de-duplication for active runs to prevent double-click duplication.

View File

@ -0,0 +1,78 @@
# Research: Backup/Restore Job Orchestration (049)
This document resolves Phase 0 open questions and records design choices.
## Decisions
### 1) Run Record storage strategy
**Decision:** Reuse existing run-record primitives instead of introducing a brand-new “unified run” subsystem in Phase 1.
- Restore + re-run restore + dry-run/preview: use the existing `restore_runs` table / `App\Models\RestoreRun`.
- Backup set capture-like operations (e.g., “add policies and capture”): reuse `bulk_operation_runs` / `App\Models\BulkOperationRun` (already used for long-running background work like bulk exports) and (if needed) extend it to satisfy FR-002 fields.
**Rationale:**
- The codebase already has multiple proven “run tables” (`restore_runs`, `inventory_sync_runs`, `backup_schedule_runs`, `bulk_operation_runs`).
- Minimizes migration risk and avoids broad refactors.
- Lets Phase 1 focus on eliminating inline heavy work while keeping UX consistent.
**Alternatives considered:**
- **Create a new generic `operation_runs` + `operation_run_items` data model** for all queued automation.
- Rejected (Phase 1): higher migration + backfill cost; high coordination risk across many features.
### 2) Status lifecycle mapping
**Decision:** Standardize at the *UI + plan* level on `queued → running → (succeeded | failed | partial)` while allowing underlying storage to keep its existing status vocabulary.
- `BulkOperationRun.status` mapping: `pending→queued`, `running→running`, `completed→succeeded`, `completed_with_errors→partial`, `failed/aborted→failed`.
- `RestoreRun.status` mapping will be aligned (e.g., `pending→queued`, `running→running`, etc.) as part of implementation.
**Rationale:**
- Keeps the specs lifecycle consistent without forcing an immediate cross-table refactor.
**Alternatives considered:**
- **Rename and normalize all run statuses across all run tables.**
- Rejected (Phase 1): touches many workflows and tests.
### 3) Idempotency & de-duplication
**Decision:** Enforce de-duplication for *active* runs via a deterministic key and a DB query gate, with an optional lock for race reduction.
- Dedupe key format: `tenant_id + operation_type + target_object_id` (plus a stable hash of relevant payload if needed).
- Behavior: if an identical run is `queued`/`running`, reuse it and return/link to it; allow a new run only after terminal.
**Rationale:**
- Matches the constitution (“Automation must be Idempotent & Observable”) and aligns with existing patterns (inventory selection hash + schedule locks).
**Alternatives considered:**
- **Cache-only locks** (`Cache::lock(...)`) without persisted keys.
- Rejected: harder to reason about after restarts; less observable.
### 4) Restore preview must be asynchronous
**Decision:** Move restore preview generation (“Generate preview” in the wizard) into a queued job which persists preview outputs to the run record.
**Rationale:**
- Preview can require Graph calls and normalization work; it should never block an interactive request.
**Alternatives considered:**
- **Keep preview synchronous** and increase timeouts.
- Rejected: timeouts, poor UX, and violates FR-001.
### 5) Notifications for progress visibility
**Decision:** Use DB notifications for state transitions (queued/running/terminal) and keep a Run detail view as the primary progress surface in Phase 1.
**Rationale:**
- Inventory sync + backup schedule runs already use this pattern.
- Survives page reloads and doesnt require the user to keep the page open.
**Alternatives considered:**
- **Frontend polling only** (no DB notifications).
- Rejected: weaker UX and weaker observability.
## Clarifications resolved
- **SC-003 includes “canceled”** while Phase 1 explicitly has “no cancel”.
- Resolution for Phase 1 planning: treat “canceled” as out-of-scope (Phase 2+) and map “aborted” (if present) into the `failed` bucket for SC accounting.

View File

@ -42,7 +42,21 @@ ### User Story 1 - Capture snapshot runs in background (Priority: P1)
---
### User Story 2 - Restore runs in background with per-item results (Priority: P1)
### User Story 2 - Backup set create/capture runs in background (Priority: P2)
An admin can create a backup set and optionally start a capture/sync operation without the request doing heavy work.
**Why this priority**: Creating backup sets is frequent and should not be coupled to long-running capture logic.
**Independent Test**: Creating a backup set returns quickly and any capture/sync work appears as a run with progress.
**Acceptance Scenarios**:
1. **Given** an admin creates a backup set with capture enabled, **When** they submit, **Then** the backup set is created and a capture run is queued.
---
### User Story 3 - Restore runs in background with per-item results (Priority: P1)
An admin can start a “restore to Intune” or “re-run restore” operation as a background run and later inspect item-level outcomes and errors.
@ -54,20 +68,7 @@ ### User Story 2 - Restore runs in background with per-item results (Priority: P
1. **Given** an admin starts a restore, **When** they confirm the action, **Then** the UI queues a run and returns immediately (no long-running request).
2. **Given** a restore run finishes with mixed outcomes, **When** the admin views the run details, **Then** they see succeeded/failed counts and a safe error summary per failed item.
---
### User Story 3 - Backup set create/capture runs in background (Priority: P2)
An admin can create a backup set and optionally start a capture/sync operation without the request doing heavy work.
**Why this priority**: Creating backup sets is frequent and should not be coupled to long-running capture logic.
**Independent Test**: Creating a backup set returns quickly and any capture/sync work appears as a run with progress.
**Acceptance Scenarios**:
1. **Given** an admin creates a backup set with capture enabled, **When** they submit, **Then** the backup set is created and a capture run is queued.
3. **Given** an admin executes a live restore, **When** the run is queued/executed, **Then** an auditable event is recorded that links to the run.
---
@ -82,6 +83,7 @@ ### User Story 4 - Dry-run/preview runs in background (Priority: P2)
**Acceptance Scenarios**:
1. **Given** an admin starts a preview run, **When** the run completes, **Then** the UI shows preview results without requiring re-execution.
2. **Given** an admin starts a preview/dry-run, **When** the run executes, **Then** no write/change is performed against the external system.
### Edge Cases
@ -135,7 +137,9 @@ ### Functional Requirements
- **FR-007 Safety rules**: Preview/dry-run MUST be safe (no writes). Live restore MUST remain guarded with explicit confirmation and an auditable trail consistent with existing safety practices.
- **FR-008 Resilience**: The system MUST handle external service throttling/outages gracefully, including retries with backoff when appropriate, and MUST end runs in a clear terminal state (failed/partial) rather than silently failing.
- **FR-008 Resilience (Post-MVP / Phase 2)**: The system MUST handle external service throttling/outages gracefully, including retries with backoff when appropriate, and MUST end runs in a clear terminal state (failed/partial) rather than silently failing.
*Note*: MVP/Phase 1 relies on existing retry behavior where present; standardized backoff + jitter hardening is scheduled post-MVP.
- **FR-009 Safe logging & data minimization**: The system MUST NOT store secrets/tokens in Run Records, notifications, or error contexts. Error context MUST be limited to a defined, safe set of fields.
@ -151,6 +155,9 @@ ### Acceptance Checks
- Item-level outcomes and safe error summaries are viewable after completion.
- Run counts reflect persisted internal item results.
- Preview/dry-run never performs writes.
- Unauthorized users cannot start runs for a tenant they do not belong to.
- Users cannot list/view run records across tenants.
- Live restore creates an auditable event linked to the run.
### Key Entities *(include if feature involves data)*
@ -164,9 +171,11 @@ ### Measurable Outcomes
- **SC-001**: For 95% of operation starts, the UI confirms “queued” within 2 seconds.
- **SC-002**: Double-clicking an operation start results in at most one queued/running run for the same tenant + operation + target.
- **SC-003**: 99% of runs end in a clear terminal state (succeeded/failed/partial/canceled) with a human-readable summary.
- **SC-003**: 99% of runs end in a clear terminal state (succeeded/failed/partial) with a human-readable summary.
- **SC-004**: Admins can locate the latest run status for an operation in under 30 seconds without requiring access to system logs.
*Note*: “canceled” is reserved for Phase 2+ (Phase 1 has no cancel support).
## Assumptions
- This feature builds on the UI safety constraints from 048: admin pages must remain usable even when the external service API is unavailable.

View File

@ -0,0 +1,202 @@
# Tasks: Backup/Restore Job Orchestration (049)
**Input**: Design documents from `specs/049-backup-restore-job-orchestration/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md
**Tests**: REQUIRED (Pest) for these runtime behavior changes.
**MVP scope**: Strictly limited to **T001T016 (US1 only)**. The **Phase 7 global progress widget (T037)** is **Phase 2** and explicitly **NOT** part of the MVP.
## Phase 1: Setup (Shared Infrastructure)
- [x] T001 Verify queue + DB notifications prerequisites in config/queue.php and database/migrations/*notifications* (add missing migration if needed)
- [x] T002 Confirm existing run tables and status enums used by RestoreRun in app/Support/RestoreRunStatus.php and database/migrations/2025_12_10_000150_create_restore_runs_table.php
- [x] T003 [P] Add quickstart sanity commands for this feature in specs/049-backup-restore-job-orchestration/quickstart.md
---
## Phase 2: Foundational (Blocking Prerequisites)
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 Add idempotency support to bulk_operation_runs via database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php
- [x] T005 Add idempotency support to restore_runs via database/migrations/2026_01_11_120002_add_idempotency_key_to_restore_runs_table.php
- [x] T006 [P] Add casts/fillables for idempotency + timestamps in app/Models/BulkOperationRun.php and app/Models/RestoreRun.php
- [x] T007 Implement idempotency key helpers in app/Support/RunIdempotency.php (build key, find active run, enforce reuse)
- [x] T008 [P] Add a read-only Filament resource to inspect run details for BulkOperationRun in app/Filament/Resources/BulkOperationRunResource.php
- [x] T009 [P] Add notification for run status transitions in app/Notifications/RunStatusChangedNotification.php (DB channel)
- [x] T010 Add unit tests for RunIdempotency helpers in tests/Unit/RunIdempotencyTest.php
**CRITICAL (must-fix before implementing any new run flows): Tenant isolation + authorization**
- [x] T042 Add tenant-scoped authorization for run list/view/start across all run flows (BulkOperationRun + RestoreRun) using policies/resources and ensure every query is tenant-scoped (e.g., app/Filament/Resources/BulkOperationRunResource.php, app/Filament/Resources/RestoreRunResource.php, and each start action/page that creates runs)
- [x] T043 [P] Add Pest feature tests that run list/view are tenant-scoped (cannot list/view another tenants runs) in tests/Feature/RunAuthorizationTenantIsolationTest.php
- [x] T044 [P] Add Pest feature tests that unaffiliated users cannot start runs (capture snapshot / restore execute / preview / backup set capture) in tests/Feature/RunStartAuthorizationTest.php
**Checkpoint**: Foundation ready (idempotency + run detail view + notifications).
---
## Phase 3: User Story 1 - Capture snapshot runs in background (Priority: P1) 🎯 MVP
**Goal**: Capturing a policy snapshot never blocks the UI; it creates/reuses a run record and processes in a queued job with visible progress.
**Independent Test**: Trigger “Capture snapshot” on a policy; the request returns quickly and a BulkOperationRun transitions `queued → running → succeeded|failed|partial`, with details viewable.
### Tests (write first)
- [x] T011 [P] [US1] Add Pest feature test that capture snapshot queues a job (no inline capture) in tests/Feature/PolicyCaptureSnapshotQueuedTest.php
- [x] T012 [P] [US1] Add Pest feature test that double-click reuses the active run (idempotency) in tests/Feature/PolicyCaptureSnapshotIdempotencyTest.php
### Implementation
- [x] T013 [US1] Create queued job to capture one policy snapshot in app/Jobs/CapturePolicySnapshotJob.php (updates BulkOperationRun counts + failures)
- [x] T014 [US1] Update UI action to create/reuse run and dispatch job in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
- [x] T015 [P] [US1] Add linking from UI notifications to BulkOperationRunResource view page in app/Filament/Resources/BulkOperationRunResource.php
- [x] T016 [US1] Ensure failures are safe/minimized (no secrets) when recording run failures in app/Services/BulkOperationService.php
**Checkpoint**: User Story 1 is independently usable and testable.
---
## Phase 4: User Story 3 - Restore runs in background with per-item results (Priority: P1)
**Goal**: Restore execution and re-run restore operate exclusively via queued jobs, with persisted per-item outcomes and safe error summaries visible in the run detail UI.
**Independent Test**: Starting restore creates/reuses a RestoreRun in `queued` state, queues execution, and later shows item outcomes without relying on logs.
### Tests (write first)
- [x] T017 [P] [US3] Add Pest feature test that restore execution reuses active run for identical (tenant+backup_set+scope) starts in tests/Feature/RestoreRunIdempotencyTest.php
- [x] T018 [P] [US3] Extend existing restore job test to assert per-item outcome persistence in tests/Feature/ExecuteRestoreRunJobTest.php
- [x] T045 [P] [US3] Add Pest feature test that live restore writes an audit event (run-id linked) in tests/Feature/RestoreAuditLoggingTest.php
### Implementation
- [x] T019 [US3] Implement idempotency key computation for restore runs (tenant + operation + target + scope hash) in app/Support/RunIdempotency.php
- [x] T020 [US3] Update restore run creation/execute flow to reuse active runs (no duplicates) in app/Filament/Resources/RestoreRunResource.php
- [x] T021 [US3] Update app/Jobs/ExecuteRestoreRunJob.php to set started/finished timestamps and emit DB notifications (queued/running/terminal)
- [x] T022 [US3] Persist deterministic per-item outcomes into restore_runs.results (keyed by backup_item_id) in app/Services/Intune/RestoreService.php
- [x] T023 [US3] Derive total/succeeded/failed counts from persisted results and surface in RestoreRunResource view/table in app/Filament/Resources/RestoreRunResource.php
- [x] T046 [US3] Ensure live restore execution emits an auditable event linked to the run (e.g., audit_logs FK or structured audit record) in app/Jobs/ExecuteRestoreRunJob.php and/or app/Services/Intune/RestoreService.php
**Checkpoint**: Restore runs are job-only, idempotent, and observable with item outcomes.
---
## Phase 5: User Story 2 - Backup set create/capture runs in background (Priority: P2)
**Goal**: Creating a backup set and adding policies to a backup set does not perform Graph-heavy snapshot capture inline; capture occurs in jobs with a run record.
**Independent Test**: Creating a backup set returns quickly and produces a BulkOperationRun showing progress; adding policies via the picker also queues work.
### Tests (write first)
- [ ] T024 [P] [US2] Add Pest feature test that backup set create does not run capture inline and instead queues a job in tests/Feature/BackupSetCreateCaptureQueuedTest.php
- [ ] T025 [P] [US2] Add Pest feature test that “Add selected” in policy picker queues background work in tests/Feature/BackupSetPolicyPickerQueuesCaptureTest.php
### Implementation
- [ ] T026 [US2] Refactor capture work out of BackupService::createBackupSet into separate methods in app/Services/Intune/BackupService.php
- [ ] T027 [US2] Create queued job to capture backup set items in app/Jobs/CaptureBackupSetJob.php (uses BackupService; updates BulkOperationRun)
- [ ] T028 [US2] Update backup set create flow to create backup_set record quickly and dispatch CaptureBackupSetJob in app/Filament/Resources/BackupSetResource.php
- [ ] T029 [US2] Create queued job to add policies to a backup set (and capture foundations if requested) in app/Jobs/AddPoliciesToBackupSetJob.php
- [ ] T030 [US2] Update bulk action in app/Livewire/BackupSetPolicyPickerTable.php to create/reuse BulkOperationRun and dispatch AddPoliciesToBackupSetJob
**Checkpoint**: Backup set capture workloads are job-only and observable.
---
## Phase 6: User Story 4 - Dry-run/preview runs in background (Priority: P2)
**Goal**: Restore preview generation is queued, persisted, and viewable without re-execution.
**Independent Test**: Clicking “Generate preview” returns quickly; a queued RestoreRun performs the diff generation asynchronously and persists preview output that the UI can display.
### Tests (write first)
- [ ] T031 [P] [US4] Add Pest feature test that preview generation queues a job (no inline RestoreDiffGenerator call) in tests/Feature/RestorePreviewQueuedTest.php
- [ ] T032 [P] [US4] Add Pest feature test that preview results persist and are reusable in tests/Feature/RestorePreviewPersistenceTest.php
- [ ] T047 [P] [US4] Add Pest feature test that preview/dry-run never performs writes (must be read-only) in tests/Feature/RestorePreviewReadOnlySafetyTest.php
### Implementation
- [ ] T033 [US4] Create queued job to generate preview diffs and persist to restore_runs.preview + metadata in app/Jobs/GenerateRestorePreviewJob.php
- [ ] T034 [US4] Update preview action in app/Filament/Resources/RestoreRunResource.php to create/reuse a dry-run RestoreRun and dispatch GenerateRestorePreviewJob
- [ ] T035 [US4] Update restore run view component to read preview from the persisted run record in resources/views/filament/forms/components/restore-run-preview.blade.php
- [ ] T036 [US4] Emit DB notifications for preview queued/running/completed/failed transitions in app/Jobs/GenerateRestorePreviewJob.php
- [ ] T048 [US4] Enforce preview/dry-run read-only behavior: block write-capable operations and record a safe failure if a write would occur (in app/Jobs/GenerateRestorePreviewJob.php and/or restore diff generation service)
**Checkpoint**: Preview is asynchronous, persisted, and visible.
---
## Phase 7: Phase 2 - Global Progress Widget (All Run Types)
- [ ] T037 [P] Add a global progress widget for restore runs (Phase 2 requirement) by extending app/Livewire/BulkOperationProgress.php or adding a dedicated Livewire component in app/Livewire/RestoreRunProgress.php
---
## Phase 8: Polish & Cross-Cutting Concerns
- [ ] T038 Ensure Graph throttling/backoff behavior is applied inside queued jobs (429/503) in app/Services/Intune/PolicySnapshotService.php and app/Services/Intune/RestoreService.php
- [ ] T039 [P] Add/extend run status notification formatting to include safe error codes/contexts in app/Notifications/RunStatusChangedNotification.php
- [ ] T040 Run formatter on modified files: vendor/bin/pint --dirty
- [ ] T041 Run targeted tests for affected areas: tests/Feature/*Restore* tests/Feature/*BackupSet* tests/Feature/*Policy* (use php artisan test with filters)
---
## Dependencies & Execution Order
### Story order
- Phase 1 → Phase 2 must complete first.
- After Phase 2:
- US1 and US3 can proceed in parallel.
- US4 can proceed in parallel but may be easiest after US3 (shared RestoreRun patterns).
- US2 can proceed independently after Phase 2.
### Dependency graph
- Setup → Foundational → { US1, US2, US3, US4 } → Polish
- Setup → Foundational → { US1, US2, US3, US4 } → Phase 2 Global Widget → Polish
- Suggested minimal MVP: Setup → Foundational → US1
---
## Parallel execution examples
### US1
- In parallel: T011 (queues test), T012 (idempotency test)
- In parallel: T013 (job), T014 (UI action update) after foundational tasks
### US2
- In parallel: T024 (create queues test), T025 (picker queues test)
- In parallel: T027 (job) and T029 (job) after BackupService refactor task T026
### US3
- In parallel: T017 (idempotency test), T018 (job behavior test)
- In parallel: T021 (job notifications) and T023 (UI view enhancements) once results format is defined
### US4
- In parallel: T031 (queues test), T032 (persistence test)
- In parallel: T033 (job) and T035 (view reads persisted preview) once run persistence shape is agreed
---
## Implementation strategy
- MVP (fastest value): deliver US1 first (policy snapshot capture becomes queued + idempotent + observable).
- Next: US3 + US4 to fully de-risk restore execution and preview.
- Then: US2 to eliminate inline Graph work from backup set flows.
## Format validation
All tasks above follow the required checklist format:
`- [ ] T### [P?] [US#?] Description with file path`

View File

@ -1,9 +1,12 @@
<?php
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\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
@ -59,10 +62,68 @@
});
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$job->handle($restoreService, app(AuditLogger::class));
$job->handle($restoreService, app(AuditLogger::class), app(BulkOperationService::class));
$restoreRun->refresh();
expect($restoreRun->started_at)->not->toBeNull();
expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value);
});
test('execute restore run job persists per-item outcomes keyed by backup_item_id', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-results',
'name' => 'Tenant Results',
'metadata' => [],
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-results',
'policy_type' => 'unknownPreviewOnlyType',
'display_name' => 'Preview-only policy',
'platform' => 'windows',
]);
$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',
],
]);
$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' => [$backupItem->id],
'preview' => [],
'results' => null,
'metadata' => [],
]);
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$job->handle(app(RestoreService::class), app(AuditLogger::class), app(BulkOperationService::class));
$restoreRun->refresh();
expect($restoreRun->completed_at)->not->toBeNull();
expect($restoreRun->results)->toBeArray();
expect($restoreRun->results['items'][(string) $backupItem->id]['backup_item_id'] ?? null)->toBe($backupItem->id);
expect($restoreRun->results['items'][(string) $backupItem->id]['status'] ?? null)->toBe('skipped');
});

View File

@ -103,9 +103,12 @@ public function request(string $method, string $path, array $options = []): Grap
actorName: 'Tester',
);
expect($run->results)->toHaveCount(1);
expect($run->results[0]['status'])->toBe('skipped');
expect($run->results[0]['reason'])->toBe('preview_only');
expect($run->results['items'] ?? [])->toHaveCount(1);
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('skipped');
expect($result['reason'] ?? null)->toBe('preview_only');
expect($client->applyCalls)->toBe(0);
});

View File

@ -103,9 +103,12 @@ public function request(string $method, string $path, array $options = []): Grap
actorName: 'Tester',
);
expect($run->results)->toHaveCount(1);
expect($run->results[0]['status'])->toBe('skipped');
expect($run->results[0]['reason'])->toBe('preview_only');
expect($run->results['items'] ?? [])->toHaveCount(1);
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('skipped');
expect($result['reason'] ?? null)->toBe('preview_only');
expect($client->applyCalls)->toBe(0);
});
@ -203,9 +206,12 @@ public function request(string $method, string $path, array $options = []): Grap
actorName: 'Tester',
);
expect($run->results)->toHaveCount(1);
expect($run->results[0]['status'])->toBe('skipped');
expect($run->results[0]['reason'])->toBe('preview_only');
expect($run->results['items'] ?? [])->toHaveCount(1);
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('skipped');
expect($result['reason'] ?? null)->toBe('preview_only');
expect($client->applyCalls)->toBe(0);
});

View File

@ -164,8 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap
)->refresh();
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['definition_value_summary']['success'])->toBe(1);
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect($result['definition_value_summary']['success'] ?? null)->toBe(1);
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration');

View File

@ -120,5 +120,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('failed');
expect($run->results[0]['reason'])->toContain('mismatch');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->toBeArray();
expect($result['reason'] ?? null)->toContain('mismatch');
});

View File

@ -1,6 +1,7 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
@ -9,12 +10,15 @@
use App\Services\Intune\PolicySnapshotService;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
it('captures a policy snapshot with scope tags when requested', function () {
Queue::fake();
$tenant = Tenant::factory()->create();
$tenant->makeCurrent();
$policy = Policy::factory()->for($tenant)->create([
@ -58,6 +62,18 @@
'include_scope_tags' => true,
]);
$job = null;
Queue::assertPushed(CapturePolicySnapshotJob::class, function (CapturePolicySnapshotJob $queuedJob) use (&$job): bool {
$job = $queuedJob;
return true;
});
expect($job)->not->toBeNull();
app()->call([$job, 'handle']);
$version = $policy->versions()->first();
expect($version)->not->toBeNull();

View File

@ -141,9 +141,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
actorName: $user->name,
)->refresh();
$backupItemId = is_array($run->requested_items) ? ($run->requested_items[0] ?? null) : null;
expect($backupItemId)->not->toBeNull();
$result = $run->results['items'][(int) $backupItemId] ?? null;
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['definition_value_summary']['success'])->toBe(5);
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect($result['definition_value_summary']['success'] ?? null)->toBe(5);
$definitionValueCreateCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues'))

View File

@ -113,7 +113,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
$this->assertDatabaseHas('audit_logs', [
'action' => 'restore.executed',
@ -201,8 +203,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('completed');
expect($run->results)->toHaveCount(1);
expect($run->results[0]['decision'])->toBe('created');
expect($run->results['foundations'] ?? [])->toHaveCount(1);
expect(($run->results['foundations'][0]['decision'] ?? null))->toBe('created');
$this->assertDatabaseHas('audit_logs', [
'action' => 'restore.foundation.created',
@ -301,8 +303,10 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('partial');
expect($run->results[0]['status'])->toBe('partial');
expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1);
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('partial');
expect($result['compliance_action_summary']['skipped'] ?? null)->toBe(1);
$this->assertDatabaseHas('audit_logs', [
'action' => 'restore.compliance.actions.mapped',
@ -517,8 +521,10 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect($graphClient->createCalls)->toBe(1);
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile');
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect($result['created_policy_id'] ?? null)->toBe('autopilot-created');
});
test('restore execution creates missing policy using contracts', function () {
@ -619,6 +625,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect($graphClient->createCalls)->toBe(1);
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy');
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('compliance-created');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect($result['created_policy_id'] ?? null)->toBe('compliance-created');
});

View File

@ -162,23 +162,26 @@ public function request(string $method, string $path, array $options = []): Grap
actorName: $user->name,
)->refresh();
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($run->status)->toBe('partial');
expect($run->results[0]['status'])->toBe('manual_required');
expect($run->results[0]['settings_apply']['manual_required'])->toBe(1);
expect($run->results[0]['settings_apply']['failed'])->toBe(0);
expect($run->results[0]['settings_apply']['issues'][0]['graph_request_id'])->toBe('req-setting-404');
expect($result['status'] ?? null)->toBe('manual_required');
expect($result['settings_apply']['manual_required'] ?? null)->toBe(1);
expect($result['settings_apply']['failed'] ?? null)->toBe(0);
expect($result['settings_apply']['issues'][0]['graph_request_id'] ?? null)->toBe('req-setting-404');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings');
expect($client->requestCalls[0]['method'])->toBe('POST');
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings');
$results = $run->results;
$results[0]['assignment_summary'] = [
$results['items'][$backupItem->id]['assignment_summary'] = [
'success' => 0,
'failed' => 1,
'skipped' => 0,
];
$results[0]['assignment_outcomes'] = [[
$results['items'][$backupItem->id]['assignment_outcomes'] = [[
'status' => 'failed',
'group_id' => 'group-1',
'mapped_group_id' => 'group-2',

View File

@ -177,13 +177,16 @@ public function request(string $method, string $path, array $options = []): Grap
actorName: $user->name,
)->refresh();
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($run->status)->toBe('partial');
expect($run->results[0]['status'])->toBe('manual_required');
expect($run->results[0]['settings_apply']['manual_required'])->toBe(1);
expect($run->results[0]['settings_apply']['failed'])->toBe(0);
expect($run->results[0]['settings_apply']['issues'][0]['graph_error_message'])->toContain('settings are read-only');
expect($run->results[0]['settings_apply']['issues'][0]['graph_request_id'])->toBe('req-123');
expect($run->results[0]['settings_apply']['issues'][0]['graph_client_request_id'])->toBe('client-abc');
expect($result['status'] ?? null)->toBe('manual_required');
expect($result['settings_apply']['manual_required'] ?? null)->toBe(1);
expect($result['settings_apply']['failed'] ?? null)->toBe(0);
expect($result['settings_apply']['issues'][0]['graph_error_message'] ?? null)->toContain('settings are read-only');
expect($result['settings_apply']['issues'][0]['graph_request_id'] ?? null)->toBe('req-123');
expect($result['settings_apply']['issues'][0]['graph_client_request_id'] ?? null)->toBe('client-abc');
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['payload'])->toHaveKey('name');
@ -527,12 +530,15 @@ public function request(string $method, string $path, array $options = []): Grap
actorName: $user->name,
)->refresh();
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($run->status)->toBe('partial');
expect($run->results[0]['status'])->toBe('partial');
expect($run->results[0]['created_policy_id'])->toBe('new-policy-123');
expect($run->results[0]['created_policy_mode'])->toBe('metadata_only');
expect($run->results[0]['settings_apply']['created_policy_id'])->toBe('new-policy-123');
expect($run->results[0]['settings_apply']['created_policy_mode'])->toBe('metadata_only');
expect($result['status'] ?? null)->toBe('partial');
expect($result['created_policy_id'] ?? null)->toBe('new-policy-123');
expect($result['created_policy_mode'] ?? null)->toBe('metadata_only');
expect($result['settings_apply']['created_policy_id'] ?? null)->toBe('new-policy-123');
expect($result['settings_apply']['created_policy_mode'] ?? null)->toBe('metadata_only');
expect($client->requestCalls)->toHaveCount(3);
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-5/settings');

View File

@ -117,7 +117,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
@ -194,7 +196,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
@ -277,7 +281,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);

View File

@ -124,7 +124,9 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
);
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
expect($result['status'] ?? null)->toBe('applied');
$this->assertDatabaseHas('audit_logs', [
'action' => 'restore.executed',

View File

@ -0,0 +1,46 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Support\RunIdempotency;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('reuses an active run on double click (idempotency)', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->for($tenant)->create();
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
->callAction('capture_snapshot', data: [
'include_assignments' => true,
'include_scope_tags' => true,
]);
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
->callAction('capture_snapshot', data: [
'include_assignments' => true,
'include_scope_tags' => true,
]);
$key = RunIdempotency::buildKey($tenant->getKey(), 'policy.capture_snapshot', $policy->getKey());
expect(BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('idempotency_key', $key)
->count())->toBe(1);
Queue::assertPushed(CapturePolicySnapshotJob::class, 1);
});

View File

@ -0,0 +1,48 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Jobs\CapturePolicySnapshotJob;
use App\Models\BulkOperationRun;
use App\Models\Policy;
use App\Services\Intune\VersionService;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
it('queues a capture snapshot job (no inline Graph capture)', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$policy = Policy::factory()->for($tenant)->create();
$this->mock(VersionService::class, function (MockInterface $mock) {
$mock->shouldReceive('captureFromGraph')->never();
});
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
->callAction('capture_snapshot', data: [
'include_assignments' => true,
'include_scope_tags' => true,
]);
Queue::assertPushed(CapturePolicySnapshotJob::class);
$run = BulkOperationRun::query()
->where('tenant_id', $tenant->id)
->where('resource', 'policies')
->where('action', 'capture_snapshot')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run->item_ids)->toBe([(string) $policy->getKey()]);
});

View File

@ -142,7 +142,10 @@ public function request(string $method, string $path, array $options = []): Grap
],
);
$summary = $run->results[0]['assignment_summary'] ?? null;
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
$summary = $result['assignment_summary'] ?? null;
expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(2);
@ -242,12 +245,15 @@ public function request(string $method, string $path, array $options = []): Grap
],
);
$summary = $run->results[0]['assignment_summary'] ?? null;
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->not->toBeNull();
$summary = $result['assignment_summary'] ?? null;
expect($summary)->not->toBeNull();
expect($summary['success'])->toBe(0);
expect($summary['failed'])->toBe(2);
expect($run->results[0]['status'])->toBe('partial');
expect($result['status'] ?? null)->toBe('partial');
});
test('restore maps assignment filter identifiers', function () {

View File

@ -0,0 +1,69 @@
<?php
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\AuditLog;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\BulkOperationService;
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('live restore execution emits an auditable event linked to the run', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-audit',
'name' => 'Tenant Audit',
'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 ($restoreRun) {
$mock->shouldReceive('executeForRun')
->once()
->andReturnUsing(function () use ($restoreRun): RestoreRun {
$restoreRun->update([
'status' => RestoreRunStatus::Completed->value,
'completed_at' => now(),
]);
return $restoreRun->refresh();
});
});
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$job->handle($restoreService, app(AuditLogger::class), app(BulkOperationService::class));
$audit = AuditLog::query()
->where('tenant_id', $tenant->id)
->where('action', 'restore.started')
->where('metadata->restore_run_id', $restoreRun->id)
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit->metadata['backup_set_id'] ?? null)->toBe($backupSet->id);
expect($audit->actor_email)->toBe('actor@example.com');
});

View File

@ -95,7 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($run->status)->toBe('failed');
$result = $run->results[0] ?? null;
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->toBeArray();
expect($result['graph_method'] ?? null)->toBe('PATCH');
expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1');

View File

@ -144,12 +144,14 @@
]],
]);
$targetGroupId = fake()->uuid();
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturnUsing(function (array $groupIds): array {
return collect($groupIds)
->mapWithKeys(function (string $id) {
$resolved = $id === 'target-group-1';
$resolved = $id === $targetGroupId;
return [$id => [
'id' => $id,
@ -178,7 +180,7 @@
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
'group_mapping' => [
'source-group-1' => 'target-group-1',
'source-group-1' => $targetGroupId,
],
])
->goToNextWizardStep()
@ -192,7 +194,7 @@
expect($run)->not->toBeNull();
expect($run->group_mapping)->toBe([
'source-group-1' => 'target-group-1',
'source-group-1' => $targetGroupId,
]);
$this->assertDatabaseHas('audit_logs', [

View File

@ -100,6 +100,7 @@
->goToNextWizardStep()
->goToNextWizardStep()
->goToNextWizardStep()
->set('data.group_mapping.group-1', 'SKIP')
->callFormComponentAction('preview_diffs', 'run_restore_preview');
$summary = $component->get('data.preview_summary');
@ -121,6 +122,9 @@
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
$previewRanAt = $summary['generated_at'] ?? now()->toIso8601String();
$component->set('data.preview_ran_at', $previewRanAt);
$component
->goToNextWizardStep()
->call('create')

View File

@ -120,6 +120,10 @@
$component
->goToNextWizardStep()
->set('data.group_mapping.source-group-1', 'SKIP')
->set('data.check_summary', $summary)
->set('data.check_results', $results)
->set('data.checks_ran_at', $checksRanAt)
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->call('create')

View File

@ -0,0 +1,102 @@
<?php
use App\Filament\Resources\RestoreRunResource;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore execution reuses active run for identical starts', function () {
Bus::fake();
$tenant = Tenant::create([
'tenant_id' => 'tenant-idempotency',
'name' => 'Tenant Idempotency',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'unknownPreviewOnlyType',
'display_name' => 'Preview-only 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);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$data = [
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
'group_mapping' => [],
'is_dry_run' => false,
'check_summary' => [
'blocking' => 0,
'warning' => 0,
'safe' => 1,
'has_blockers' => false,
],
'check_results' => [],
'checks_ran_at' => now()->toIso8601String(),
'preview_ran_at' => now()->toIso8601String(),
'acknowledged_impact' => true,
'tenant_confirm' => 'Tenant Idempotency',
];
$first = RestoreRunResource::createRestoreRun($data);
$second = RestoreRunResource::createRestoreRun($data);
expect($first->id)->toBe($second->id);
expect(RestoreRun::count())->toBe(1);
$run = RestoreRun::query()->first();
expect($run)->not->toBeNull();
expect($run->status)->toBe(RestoreRunStatus::Queued->value);
Bus::assertDispatchedTimes(ExecuteRestoreRunJob::class, 1);
});

View File

@ -110,7 +110,7 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->applyPolicyCalls)->toHaveCount(0);
$result = $run->results[0] ?? null;
$result = $run->results['items'][$backupItem->id] ?? null;
expect($result)->toBeArray();
expect($result['status'] ?? null)->toBe('skipped');
expect($result['restore_mode'] ?? null)->toBe('preview-only');

View File

@ -0,0 +1,58 @@
<?php
use App\Filament\Resources\BulkOperationRunResource;
use App\Models\BulkOperationRun;
use App\Models\Tenant;
use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('bulk operation runs are listed for the active tenant', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
BulkOperationRun::factory()->create([
'tenant_id' => $tenantA->getKey(),
'resource' => 'tenant_a',
'action' => 'alpha',
]);
BulkOperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'resource' => 'tenant_b',
'action' => 'beta',
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(BulkOperationRunResource::getUrl('index', tenant: $tenantA))
->assertOk()
->assertSee('tenant_a')
->assertDontSee('tenant_b');
});
test('bulk operation run view is forbidden cross-tenant (403)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$runB = BulkOperationRun::factory()->create([
'tenant_id' => $tenantB->getKey(),
'resource' => 'tenant_b',
'action' => 'beta',
]);
$user = User::factory()->create();
$user->tenants()->syncWithoutDetaching([
$tenantA->getKey() => ['role' => 'owner'],
$tenantB->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user)
->get(BulkOperationRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA))
->assertForbidden();
});

View File

@ -0,0 +1,34 @@
<?php
use App\Filament\Pages\InventoryLanding;
use App\Models\BulkOperationRun;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Services\Inventory\InventorySyncService;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
it('rejects cross-tenant run starts (403) with no run records created', function () {
Queue::fake();
[$user, $tenantA] = createUserWithTenant(role: 'owner');
$tenantB = Tenant::factory()->create();
$this->actingAs($user);
Filament::setTenant($tenantA, true);
$sync = app(InventorySyncService::class);
$allTypes = $sync->defaultSelectionPayload()['policy_types'];
Livewire::test(InventoryLanding::class)
->callAction('run_inventory_sync', data: ['tenant_id' => $tenantB->getKey(), 'policy_types' => $allTypes])
->assertStatus(403);
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
expect(BulkOperationRun::query()->where('tenant_id', $tenantB->id)->exists())->toBeFalse();
});

View File

@ -0,0 +1,57 @@
<?php
use App\Models\BulkOperationRun;
use App\Models\RestoreRun;
use App\Support\RunIdempotency;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds a deterministic 64 char sha256 idempotency key', function () {
$keyA1 = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'abc', ['b' => 2, 'a' => 1]);
$keyA2 = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'abc', ['a' => 1, 'b' => 2]);
$keyB = RunIdempotency::buildKey(1, 'policy.capture_snapshot', 'def', ['a' => 1, 'b' => 2]);
expect($keyA1)->toBe($keyA2)
->and($keyA1)->not->toBe($keyB)
->and($keyA1)->toMatch('/^[a-f0-9]{64}$/');
});
it('finds only active bulk operation runs by idempotency key', function () {
$pending = BulkOperationRun::factory()->create([
'idempotency_key' => RunIdempotency::buildKey(1, 'bulk.policy.capture_snapshot', 'abc'),
'status' => 'pending',
]);
$completed = BulkOperationRun::factory()->create([
'tenant_id' => $pending->tenant_id,
'user_id' => $pending->user_id,
'idempotency_key' => $pending->idempotency_key,
'status' => 'completed',
]);
expect(RunIdempotency::findActiveBulkOperationRun($pending->tenant_id, $pending->idempotency_key))
->not->toBeNull()
->id->toBe($pending->id);
expect(RunIdempotency::findActiveBulkOperationRun($pending->tenant_id, $completed->idempotency_key))
->id->toBe($pending->id);
});
it('finds only active restore runs by idempotency key', function () {
$active = RestoreRun::factory()->create([
'idempotency_key' => RunIdempotency::buildKey(1, 'restore.execute', 123),
'status' => 'queued',
]);
RestoreRun::factory()->create([
'tenant_id' => $active->tenant_id,
'backup_set_id' => $active->backup_set_id,
'idempotency_key' => $active->idempotency_key,
'status' => 'completed',
]);
expect(RunIdempotency::findActiveRestoreRun($active->tenant_id, $active->idempotency_key))
->not->toBeNull()
->id->toBe($active->id);
});