Compare commits

...

20 Commits

Author SHA1 Message Date
e19aa09ae0 feat(wizard): Add restore from policy version (#15)
Implements the "Restore via Wizard" action on the PolicyVersion resource.

This allows a user to initiate a restore run directly from a specific policy version snapshot.

- Adds a "Restore via Wizard" action to the PolicyVersion table.
- This action creates a single-item BackupSet from the selected version.
- The CreateRestoreRun wizard is now pre-filled from query parameters.
- Adds feature tests to cover the new workflow.
- Updates tasks.md to reflect the completed work.

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #15
2025-12-31 19:02:28 +00:00
Ahmed Darrazi
e1cda6a1dc merge: agent session work 2025-12-31 13:28:01 +01:00
Ahmed Darrazi
44b4a6adf0 fix: prime group mapping state 2025-12-31 13:27:50 +01:00
Ahmed Darrazi
711e012827 merge: agent session work 2025-12-31 11:53:30 +01:00
Ahmed Darrazi
9e3c2b3011 fix: accept object group mapping in checks 2025-12-31 11:53:03 +01:00
Ahmed Darrazi
948af56185 merge: agent session work 2025-12-31 01:51:03 +01:00
Ahmed Darrazi
26755df017 feat: add confirm & queued execute to restore wizard 2025-12-31 01:50:33 +01:00
Ahmed Darrazi
4c9544e9b7 merge: agent session work 2025-12-30 23:38:56 +01:00
Ahmed Darrazi
5e16c25fca fix: show check/preview actions 2025-12-30 23:38:05 +01:00
Ahmed Darrazi
a43fef535b merge: agent session work 2025-12-30 22:06:11 +01:00
Ahmed Darrazi
a58db008f8 feat: add preview diff step 2025-12-30 22:05:57 +01:00
Ahmed Darrazi
cd76fa5dd7 merge: agent session work 2025-12-30 21:50:05 +01:00
Ahmed Darrazi
f32fdfb1e4 feat: add restore risk checks 2025-12-30 21:49:38 +01:00
Ahmed Darrazi
8ba2aae82e merge: agent session work 2025-12-30 21:29:59 +01:00
Ahmed Darrazi
2b9b649549 feat: group restore item selection 2025-12-30 21:29:41 +01:00
Ahmed Darrazi
7e4c9bb610 merge: agent session work 2025-12-30 10:44:25 +01:00
Ahmed Darrazi
e7d21e0eb8 feat: gate restore wizard to dry-run 2025-12-30 10:44:18 +01:00
Ahmed Darrazi
0795d31abe merge: agent session work 2025-12-30 03:24:00 +01:00
Ahmed Darrazi
3cf4aa2cf4 feat: restore run wizard phase 1 scaffold 2025-12-30 03:23:48 +01:00
Ahmed Darrazi
6844bc1c17 spec: restore run wizard 2025-12-30 02:56:28 +01:00
22 changed files with 3775 additions and 70 deletions

View File

@ -6,6 +6,8 @@
use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionPruneJob;
use App\Jobs\BulkPolicyVersionRestoreJob; use App\Jobs\BulkPolicyVersionRestoreJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
@ -13,6 +15,7 @@
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff; use App\Services\Intune\VersionDiff;
use BackedEnum; use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@ -183,6 +186,96 @@ public static function table(Table $table): Table
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false), ->openUrlInNewTab(false),
Actions\ActionGroup::make([ Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->action(function (PolicyVersion $record) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
$policy = $record->policy;
if (! $policy) {
Notification::make()
->title('Policy could not be found for this version')
->danger()
->send();
return;
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$record->version_number
),
'created_by' => $user?->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'policy_id' => $policy->id,
],
]);
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->captured_at?->toIso8601String(),
];
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
}
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_version_id' => $record->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
'payload' => $record->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $record->assignments,
]);
return redirect()->to(RestoreRunResource::getUrl('create', [
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]));
}),
Actions\Action::make('archive') Actions\Action::make('archive')
->label('Archive') ->label('Archive')
->color('danger') ->color('danger')

View File

@ -6,6 +6,7 @@
use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob;
use App\Jobs\BulkRestoreRunRestoreJob; use App\Jobs\BulkRestoreRunRestoreJob;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\RestoreRun; use App\Models\RestoreRun;
@ -13,7 +14,11 @@
use App\Services\BulkOperationService; use App\Services\BulkOperationService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -26,13 +31,16 @@
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
class RestoreRunResource extends Resource class RestoreRunResource extends Resource
@ -69,8 +77,10 @@ public static function form(Schema $schema): Schema
}) })
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void { ->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []); $set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', []); $set('group_mapping', []);
$set('is_dry_run', true);
}) })
->required(), ->required(),
Forms\Components\CheckboxList::make('backup_item_ids') Forms\Components\CheckboxList::make('backup_item_ids')
@ -137,6 +147,491 @@ public static function form(Schema $schema): Schema
]); ]);
} }
/**
* @return array<int, Step>
*/
public static function getWizardSteps(): array
{
return [
Step::make('Select Backup Set')
->description('What are we restoring from?')
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->orderByDesc('created_at')
->get()
->mapWithKeys(function (BackupSet $set) {
$label = sprintf(
'%s • %s items • %s',
$set->name,
$set->item_count ?? 0,
optional($set->created_at)->format('Y-m-d H:i')
);
return [$set->id => $label];
});
})
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $get('backup_set_id'),
scopeMode: 'all',
selectedItemIds: null,
tenant: Tenant::current(),
));
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
})
->required(),
]),
Step::make('Define Restore Scope')
->description('What exactly should be restored?')
->schema([
Forms\Components\Radio::make('scope_mode')
->label('Scope')
->options([
'all' => 'All items (default)',
'selected' => 'Selected items only',
])
->default('all')
->reactive()
->afterStateUpdated(function (Set $set, Get $get, $state): void {
$backupSetId = $get('backup_set_id');
$tenant = Tenant::current();
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
if ($state === 'all') {
$set('backup_item_ids', null);
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'all',
selectedItemIds: null,
tenant: $tenant,
));
return;
}
$set('group_mapping', []);
$set('backup_item_ids', []);
})
->required(),
Forms\Components\Select::make('backup_item_ids')
->label('Items to restore')
->multiple()
->searchable()
->searchValues()
->searchDebounce(400)
->optionsLimit(300)
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$tenant = Tenant::current();
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
));
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
})
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
->hintActions([
Actions\Action::make('select_all_backup_items')
->label('Select all')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')) && $get('scope_mode') === 'selected')
->action(function (Get $get, Set $set): void {
$groupedOptions = static::restoreItemGroupedOptions($get('backup_set_id'));
$allItemIds = [];
foreach ($groupedOptions as $options) {
$allItemIds = array_merge($allItemIds, array_keys($options));
}
$set('backup_item_ids', array_values($allItemIds), shouldCallUpdatedHooks: true);
}),
Actions\Action::make('clear_backup_items')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
])
->helperText('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'),
Section::make('Group mapping')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
);
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
$groupId = $group['id'];
$label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}")
->label($label)
->options([
'SKIP' => 'Skip assignment',
])
->searchable()
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('check_summary', null);
$set('check_results', []);
$set('checks_ran_at', null);
$set('preview_summary', null);
$set('preview_diffs', []);
$set('preview_ran_at', null);
})
->helperText('Choose a target group or select Skip.');
}, $unresolved);
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return false;
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
) !== [];
}),
]),
Step::make('Safety & Conflict Checks')
->description('Is this dangerous?')
->schema([
Forms\Components\Hidden::make('check_summary')
->default(null),
Forms\Components\Hidden::make('checks_ran_at')
->default(null),
Forms\Components\ViewField::make('check_results')
->label('Checks')
->default([])
->view('filament.forms.components.restore-run-checks')
->viewData(fn (Get $get): array => [
'summary' => $get('check_summary'),
'ranAt' => $get('checks_ran_at'),
])
->hintActions([
Actions\Action::make('run_restore_checks')
->label('Run checks')
->icon('heroicon-o-shield-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
Notification::make()
->title('Unable to run checks')
->body('Backup set is not available for the active tenant.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$groupMapping = static::normalizeGroupMapping($get('group_mapping'));
$checker = app(RestoreRiskChecker::class);
$outcome = $checker->check(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
$summary = $outcome['summary'] ?? [];
$blockers = (int) ($summary['blocking'] ?? 0);
$warnings = (int) ($summary['warning'] ?? 0);
if ($blockers > 0) {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
}
Notification::make()
->title('Safety checks completed')
->body("Blocking: {$blockers} • Warnings: {$warnings}")
->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success'))
->send();
}),
Actions\Action::make('clear_restore_checks')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('check_summary', null, shouldCallUpdatedHooks: true);
$set('check_results', [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
}),
])
->helperText('Run checks after defining scope and mapping missing groups.'),
]),
Step::make('Preview')
->description('Dry-run preview')
->schema([
Forms\Components\Hidden::make('preview_summary')
->default(null),
Forms\Components\Hidden::make('preview_ran_at')
->default(null)
->required(),
Forms\Components\ViewField::make('preview_diffs')
->label('Preview')
->default([])
->view('filament.forms.components.restore-run-preview')
->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'),
])
->hintActions([
Actions\Action::make('run_restore_preview')
->label('Generate preview')
->icon('heroicon-o-eye')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
Notification::make()
->title('Unable to generate preview')
->body('Backup set is not available for the active tenant.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$generator = app(RestoreDiffGenerator::class);
$outcome = $generator->generate(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
);
$summary = $outcome['summary'] ?? [];
$diffs = $outcome['diffs'] ?? [];
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
$set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
Notification::make()
->title('Preview generated')
->body("Policies: {$policiesChanged}/{$policiesTotal} changed")
->status($policiesChanged > 0 ? 'warning' : 'success')
->send();
}),
Actions\Action::make('clear_restore_preview')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
}),
])
->helperText('Generate a normalized diff preview before creating the dry-run restore.'),
]),
Step::make('Confirm & Execute')
->description('Point of no return')
->schema([
Forms\Components\Placeholder::make('confirm_environment')
->label('Environment')
->content(fn (): string => app()->environment('production') ? 'prod' : 'test'),
Forms\Components\Placeholder::make('confirm_tenant_label')
->label('Tenant hard-confirm label')
->content(function (): string {
$tenant = Tenant::current();
if (! $tenant) {
return '';
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
}),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true)
->reactive()
->disabled(function (Get $get): bool {
if (! filled($get('checks_ran_at'))) {
return true;
}
$summary = $get('check_summary');
if (! is_array($summary)) {
return false;
}
return (int) ($summary['blocking'] ?? 0) > 0;
})
->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'),
Forms\Components\Checkbox::make('acknowledged_impact')
->label('I reviewed the impact (checks + preview)')
->accepted()
->visible(fn (Get $get): bool => $get('is_dry_run') === false),
Forms\Components\TextInput::make('tenant_confirm')
->label('Type the tenant label to confirm execution')
->required(fn (Get $get): bool => $get('is_dry_run') === false)
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
->in(function (): array {
$tenant = Tenant::current();
if (! $tenant) {
return [];
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
})
->validationMessages([
'in' => 'Tenant hard-confirm does not match.',
])
->helperText(function (): string {
$tenant = Tenant::current();
if (! $tenant) {
return '';
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
return "Type: {$expected}";
}),
]),
];
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -533,11 +1028,72 @@ private static function restoreItemOptionData(?int $backupSetId): array
]; ];
} }
static $cache = []; $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
$cacheKey = $tenant->getKey().':'.$backupSetId;
if (isset($cache[$cacheKey])) { return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
return $cache[$cacheKey]; $items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
})
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$name);
});
$options = [];
$descriptions = [];
foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type);
$typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled';
$platform = $item->platform ?? $meta['platform'] ?? null;
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$options[$item->id] = $displayName;
$parts = array_filter([
$category,
$typeLabel,
$platform,
"restore: {$restore}",
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);
$descriptions[$item->id] = implode(' • ', $parts);
}
return [
'options' => $options,
'descriptions' => $descriptions,
];
});
}
/**
* @return array<string, array<int|string, string>>
*/
private static function restoreItemGroupedOptions(?int $backupSetId): array
{
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
} }
$items = BackupItem::query() $items = BackupItem::query()
@ -548,49 +1104,40 @@ private static function restoreItemOptionData(?int $backupSetId): array
->orWhereDoesntHave('policy') ->orWhereDoesntHave('policy')
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
}) })
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at']) ->with(['policy:id,display_name'])
->get() ->get()
->sortBy(function (BackupItem $item) { ->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type); $meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies'; $category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$typeLabel = $meta['label'] ?? $item->policy_type;
$platform = $item->platform ?? $meta['platform'] ?? null;
$name = strtolower($item->resolvedDisplayName()); $name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$name); return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
}); });
$options = []; $groups = [];
$descriptions = [];
foreach ($items as $item) { foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type); $meta = static::typeMeta($item->policy_type);
$typeLabel = $meta['label'] ?? $item->policy_type; $typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies'; $category = $meta['category'] ?? 'Policies';
$restore = $meta['restore'] ?? 'enabled'; $platform = $item->platform ?? $meta['platform'] ?? 'all';
$platform = $item->platform ?? $meta['platform'] ?? null; $restoreMode = $meta['restore'] ?? 'enabled';
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$options[$item->id] = $displayName; $groupLabel = implode(' • ', array_filter([
$parts = array_filter([
$category, $category,
$typeLabel, $typeLabel,
$platform, $platform,
"restore: {$restore}", $restoreMode === 'preview-only' ? 'preview-only' : null,
$versionNumber ? "version: {$versionNumber}" : null, ]));
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);
$descriptions[$item->id] = implode(' • ', $parts); $groups[$groupLabel] ??= [];
$groups[$groupLabel][$item->id] = $item->resolvedDisplayName();
} }
return $cache[$cacheKey] = [ return $groups;
'options' => $options,
'descriptions' => $descriptions,
];
} }
public static function createRestoreRun(array $data): RestoreRun public static function createRestoreRun(array $data): RestoreRun
@ -608,15 +1155,170 @@ public static function createRestoreRun(array $data): RestoreRun
/** @var RestoreService $service */ /** @var RestoreService $service */
$service = app(RestoreService::class); $service = app(RestoreService::class);
return $service->execute( $scopeMode = $data['scope_mode'] ?? 'all';
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
$checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null;
$checksRanAt = $data['checks_ran_at'] ?? null;
$previewSummary = $data['preview_summary'] ?? null;
$previewDiffs = $data['preview_diffs'] ?? null;
$previewRanAt = $data['preview_ran_at'] ?? null;
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
if (! $isDryRun) {
if (! is_array($checkSummary) || ! filled($checksRanAt)) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks before executing.',
]);
}
$blocking = (int) ($checkSummary['blocking'] ?? 0);
$hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0));
if ($blocking > 0 || $hasBlockers) {
throw ValidationException::withMessages([
'check_summary' => 'Blocking checks must be resolved before executing.',
]);
}
if (! filled($previewRanAt)) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview before executing.',
]);
}
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
throw ValidationException::withMessages([
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
]);
}
$tenantConfirm = $data['tenant_confirm'] ?? null;
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
throw ValidationException::withMessages([
'tenant_confirm' => 'Tenant hard-confirm does not match.',
]);
}
}
if ($isDryRun) {
$restoreRun = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: true,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $groupMapping,
);
$metadata = $restoreRun->metadata ?? [];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$restoreRun->update(['metadata' => $metadata]);
return $restoreRun->refresh();
}
$preview = $service->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
app(AuditLogger::class)->log(
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, action: 'restore.queued',
selectedItemIds: $data['backup_item_ids'] ?? null, context: [
dryRun: (bool) ($data['is_dry_run'] ?? true), 'metadata' => [
actorEmail: auth()->user()?->email, 'restore_run_id' => $restoreRun->id,
actorName: auth()->user()?->name, 'backup_set_id' => $backupSet->id,
groupMapping: $data['group_mapping'] ?? [], ],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success',
); );
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName);
return $restoreRun->refresh();
} }
/** /**
@ -703,6 +1405,111 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem
return $unresolved; return $unresolved;
} }
/**
* @param array<int>|null $selectedItemIds
* @return array<string, string|null>
*/
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $tenant): array
{
if (! $tenant || ! $backupSetId) {
return [];
}
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant,
);
$placeholders = [];
foreach ($unresolved as $group) {
$groupId = $group['id'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$placeholders[$groupId] = null;
}
return $placeholders;
}
/**
* @return array<string, string>
*/
private static function normalizeGroupMapping(mixed $mapping): array
{
if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$mapping = $mapping->toArray();
}
if ($mapping instanceof \stdClass) {
$mapping = (array) $mapping;
}
if (! is_array($mapping)) {
return [];
}
$result = [];
if (array_key_exists('group_mapping', $mapping)) {
$nested = $mapping['group_mapping'];
if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) {
$nested = $nested->toArray();
}
if ($nested instanceof \stdClass) {
$nested = (array) $nested;
}
if (is_array($nested)) {
$mapping = $nested;
}
}
foreach ($mapping as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$sourceGroupId = str_starts_with($key, 'group_mapping.')
? substr($key, strlen('group_mapping.'))
: $key;
if ($sourceGroupId === '') {
continue;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if (is_array($value) || $value instanceof \stdClass) {
$value = (array) $value;
$value = $value['value'] ?? $value['id'] ?? null;
}
if (is_string($value)) {
$value = trim($value);
$result[$sourceGroupId] = $value !== '' ? $value : null;
continue;
}
$result[$sourceGroupId] = null;
}
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -3,13 +3,118 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
{ {
use HasWizard;
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
public function getSteps(): array
{
return RestoreRunResource::getWizardSteps();
}
protected function afterFill(): void
{
$backupSetIdRaw = request()->query('backup_set_id');
if (! is_numeric($backupSetIdRaw)) {
return;
}
$backupSetId = (int) $backupSetIdRaw;
if ($backupSetId <= 0) {
return;
}
$tenant = Tenant::current();
if (! $tenant) {
return;
}
$belongsToTenant = BackupSet::query()
->where('tenant_id', $tenant->id)
->whereKey($backupSetId)
->exists();
if (! $belongsToTenant) {
return;
}
$backupItemIds = $this->normalizeBackupItemIds(request()->query('backup_item_ids'));
$scopeModeRaw = request()->query('scope_mode');
$scopeMode = in_array($scopeModeRaw, ['all', 'selected'], true)
? $scopeModeRaw
: ($backupItemIds !== [] ? 'selected' : 'all');
$this->data['backup_set_id'] = $backupSetId;
$this->form->callAfterStateUpdated('data.backup_set_id');
$this->data['scope_mode'] = $scopeMode;
$this->form->callAfterStateUpdated('data.scope_mode');
if ($scopeMode === 'selected') {
if ($backupItemIds !== []) {
$this->data['backup_item_ids'] = $backupItemIds;
}
$this->form->callAfterStateUpdated('data.backup_item_ids');
}
}
/**
* @return array<int>
*/
private function normalizeBackupItemIds(mixed $raw): array
{
if (is_string($raw)) {
$raw = array_filter(array_map('trim', explode(',', $raw)));
}
if (! is_array($raw)) {
return [];
}
$itemIds = [];
foreach ($raw as $value) {
if (is_int($value) && $value > 0) {
$itemIds[] = $value;
continue;
}
if (is_string($value) && ctype_digit($value)) {
$itemId = (int) $value;
if ($itemId > 0) {
$itemIds[] = $itemId;
}
}
}
$itemIds = array_values(array_unique($itemIds));
sort($itemIds);
return $itemIds;
}
protected function getSubmitFormAction(): Action
{
return parent::getSubmitFormAction()
->label('Create restore run')
->icon('heroicon-o-check-circle');
}
protected function handleRecordCreation(array $data): Model protected function handleRecordCreation(array $data): Model
{ {
return RestoreRunResource::createRestoreRun($data); return RestoreRunResource::createRestoreRun($data);

View File

@ -0,0 +1,134 @@
<?php
namespace App\Jobs;
use App\Models\RestoreRun;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class ExecuteRestoreRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $restoreRunId,
public ?string $actorEmail = null,
public ?string $actorName = null,
) {}
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
{
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
if (! $restoreRun) {
return;
}
if ($restoreRun->status !== RestoreRunStatus::Queued->value) {
return;
}
$tenant = $restoreRun->tenant;
$backupSet = $restoreRun->backupSet;
if (! $tenant || ! $backupSet || $backupSet->trashed()) {
$restoreRun->update([
'status' => RestoreRunStatus::Failed->value,
'failure_reason' => 'Backup set is archived or unavailable.',
'completed_at' => CarbonImmutable::now(),
]);
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'restore.failed',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $restoreRun->backup_set_id,
'reason' => 'Backup set is archived or unavailable.',
],
],
actorEmail: $this->actorEmail,
actorName: $this->actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'failed',
);
}
return;
}
$restoreRun->update([
'status' => RestoreRunStatus::Running->value,
'started_at' => CarbonImmutable::now(),
'failure_reason' => null,
]);
$auditLogger->log(
tenant: $tenant,
action: 'restore.started',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $this->actorEmail,
actorName: $this->actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success',
);
try {
$restoreService->executeForRun(
restoreRun: $restoreRun,
tenant: $tenant,
backupSet: $backupSet,
actorEmail: $this->actorEmail,
actorName: $this->actorName,
);
} catch (Throwable $throwable) {
$restoreRun->refresh();
if ($restoreRun->status === RestoreRunStatus::Running->value) {
$restoreRun->update([
'status' => RestoreRunStatus::Failed->value,
'failure_reason' => $throwable->getMessage(),
'completed_at' => CarbonImmutable::now(),
]);
}
if ($tenant) {
$auditLogger->log(
tenant: $tenant,
action: 'restore.failed',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'reason' => $throwable->getMessage(),
],
],
actorEmail: $this->actorEmail,
actorName: $this->actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'failed',
);
}
throw $throwable;
}
}
}

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Support\RestoreRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -35,17 +37,30 @@ public function backupSet(): BelongsTo
return $this->belongsTo(BackupSet::class)->withTrashed(); return $this->belongsTo(BackupSet::class)->withTrashed();
} }
public function scopeDeletable($query) public function scopeDeletable(Builder $query): Builder
{ {
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); return $query->whereIn('status', array_map(
static fn (RestoreRunStatus $status): string => $status->value,
[
RestoreRunStatus::Draft,
RestoreRunStatus::Scoped,
RestoreRunStatus::Checked,
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
]
));
} }
public function isDeletable(): bool public function isDeletable(): bool
{ {
$status = strtolower(trim((string) $this->status)); $status = RestoreRunStatus::fromString($this->status);
$status = str_replace([' ', '-'], '_', $status);
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); return $status?->isDeletable() ?? false;
} }
// Group mapping helpers // Group mapping helpers

View File

@ -0,0 +1,248 @@
<?php
namespace App\Services\Intune;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class RestoreDiffGenerator
{
public function __construct(
private readonly PolicyNormalizer $policyNormalizer,
private readonly VersionDiff $versionDiff,
) {}
/**
* @param array<int>|null $selectedItemIds
* @return array{summary: array<string, mixed>, diffs: array<int, array<string, mixed>>}
*/
public function generate(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array
{
if ($backupSet->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
}
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$items = $this->loadItems($backupSet, $selectedItemIds);
$policyItems = $items
->reject(fn (BackupItem $item): bool => $item->isFoundation())
->values();
$policyIds = $policyItems
->pluck('policy_id')
->filter()
->unique()
->values()
->all();
$latestVersions = $this->latestVersionsByPolicyId($tenant, $policyIds);
$maxDetailedDiffs = 25;
$maxEntriesPerSection = 200;
$policiesChanged = 0;
$assignmentsChanged = 0;
$scopeTagsChanged = 0;
$diffs = [];
$diffsOmitted = 0;
foreach ($policyItems as $index => $item) {
$policyId = $item->policy_id ? (int) $item->policy_id : null;
$currentVersion = $policyId ? ($latestVersions[$policyId] ?? null) : null;
$currentSnapshot = is_array($currentVersion?->snapshot) ? $currentVersion->snapshot : [];
$backupSnapshot = is_array($item->payload) ? $item->payload : [];
$policyType = (string) ($item->policy_type ?? '');
$platform = $item->platform;
$from = $this->policyNormalizer->flattenForDiff($currentSnapshot, $policyType, $platform);
$to = $this->policyNormalizer->flattenForDiff($backupSnapshot, $policyType, $platform);
$diff = $this->versionDiff->compare($from, $to);
$summary = $diff['summary'] ?? ['added' => 0, 'removed' => 0, 'changed' => 0];
$hasPolicyChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0;
if ($hasPolicyChanges) {
$policiesChanged++;
}
$assignmentDiff = $this->assignmentsChanged($item->assignments, $currentVersion?->assignments);
if ($assignmentDiff) {
$assignmentsChanged++;
}
$scopeTagDiff = $this->scopeTagsChanged($item, $currentVersion);
if ($scopeTagDiff) {
$scopeTagsChanged++;
}
$diffEntry = [
'backup_item_id' => $item->id,
'display_name' => $item->resolvedDisplayName(),
'policy_identifier' => $item->policy_identifier,
'policy_type' => $policyType,
'platform' => $platform,
'action' => $currentVersion ? 'update' : 'create',
'diff' => [
'summary' => $summary,
'added' => [],
'removed' => [],
'changed' => [],
],
'assignments_changed' => $assignmentDiff,
'scope_tags_changed' => $scopeTagDiff,
'diff_omitted' => false,
'diff_truncated' => false,
];
if ($index >= $maxDetailedDiffs) {
$diffEntry['diff_omitted'] = true;
$diffEntry['diff_truncated'] = true;
$diffEntry['diff'] = [
'summary' => $summary,
];
$diffsOmitted++;
$diffs[] = $diffEntry;
continue;
}
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
$diffEntry['diff_truncated'] = count($added) > $maxEntriesPerSection
|| count($removed) > $maxEntriesPerSection
|| count($changed) > $maxEntriesPerSection;
$diffEntry['diff'] = [
'summary' => $summary,
'added' => array_slice($added, 0, $maxEntriesPerSection, true),
'removed' => array_slice($removed, 0, $maxEntriesPerSection, true),
'changed' => array_slice($changed, 0, $maxEntriesPerSection, true),
];
$diffs[] = $diffEntry;
}
return [
'summary' => [
'generated_at' => CarbonImmutable::now()->toIso8601String(),
'policies_total' => $policyItems->count(),
'policies_changed' => $policiesChanged,
'assignments_changed' => $assignmentsChanged,
'scope_tags_changed' => $scopeTagsChanged,
'diffs_detailed' => min($policyItems->count(), $maxDetailedDiffs),
'diffs_omitted' => $diffsOmitted,
'limits' => [
'max_detailed_diffs' => $maxDetailedDiffs,
'max_entries_per_section' => $maxEntriesPerSection,
],
],
'diffs' => $diffs,
];
}
/**
* @param array<int>|null $selectedItemIds
* @return Collection<int, BackupItem>
*/
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
{
$query = $backupSet->items()->getQuery();
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
return $query->orderBy('id')->get();
}
/**
* @param array<int, int> $policyIds
* @return array<int, PolicyVersion>
*/
private function latestVersionsByPolicyId(Tenant $tenant, array $policyIds): array
{
if ($policyIds === []) {
return [];
}
$latestVersionsQuery = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->whereIn('policy_id', $policyIds)
->selectRaw('policy_id, max(version_number) as version_number')
->groupBy('policy_id');
return PolicyVersion::query()
->where('tenant_id', $tenant->id)
->joinSub($latestVersionsQuery, 'latest_versions', function ($join): void {
$join->on('policy_versions.policy_id', '=', 'latest_versions.policy_id')
->on('policy_versions.version_number', '=', 'latest_versions.version_number');
})
->get()
->keyBy('policy_id')
->all();
}
private function assignmentsChanged(?array $backupAssignments, ?array $currentAssignments): bool
{
$backup = $this->normalizeAssignments($backupAssignments);
$current = $this->normalizeAssignments($currentAssignments);
return $backup !== $current;
}
private function scopeTagsChanged(BackupItem $backupItem, ?PolicyVersion $currentVersion): bool
{
$backupIds = $backupItem->scope_tag_ids;
$backupIds = is_array($backupIds) ? $backupIds : [];
$backupIds = array_values(array_filter($backupIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
sort($backupIds);
$scopeTags = $currentVersion?->scope_tags;
$currentIds = is_array($scopeTags) ? ($scopeTags['ids'] ?? []) : [];
$currentIds = is_array($currentIds) ? $currentIds : [];
$currentIds = array_values(array_filter($currentIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0'));
sort($currentIds);
return $backupIds !== $currentIds;
}
/**
* @return array<int, array<string, mixed>>
*/
private function normalizeAssignments(?array $assignments): array
{
$assignments = is_array($assignments) ? $assignments : [];
$normalized = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$normalized[] = $assignment;
}
usort($normalized, function (array $a, array $b): int {
$left = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
$right = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
return $left <=> $right;
});
return $normalized;
}
}

View File

@ -0,0 +1,608 @@
<?php
namespace App\Services\Intune;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\GroupResolver;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class RestoreRiskChecker
{
public function __construct(
private readonly GroupResolver $groupResolver,
) {}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, string|null> $groupMapping
* @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array<int, array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}>}
*/
public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, array $groupMapping = []): array
{
if ($backupSet->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
}
$items = $this->loadItems($backupSet, $selectedItemIds);
$policyItems = $items
->reject(fn (BackupItem $item): bool => $item->isFoundation())
->values();
$results = [];
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
$results[] = $this->checkStalePolicies($tenant, $policyItems);
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
$results = array_values(array_filter($results));
$summary = [
'blocking' => 0,
'warning' => 0,
'safe' => 0,
'has_blockers' => false,
];
foreach ($results as $result) {
$severity = $result['severity'] ?? 'safe';
if (! in_array($severity, ['blocking', 'warning', 'safe'], true)) {
$severity = 'safe';
}
$summary[$severity]++;
}
$summary['has_blockers'] = $summary['blocking'] > 0;
return [
'summary' => $summary,
'results' => $results,
];
}
/**
* @param array<int>|null $selectedItemIds
* @return Collection<int, BackupItem>
*/
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
{
$query = $backupSet->items()->getQuery();
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
return $query->orderBy('id')->get();
}
/**
* @param Collection<int, BackupItem> $policyItems
* @param array<string, string|null> $groupMapping
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkOrphanedGroups(Tenant $tenant, Collection $policyItems, array $groupMapping): ?array
{
[$groupIds, $sourceNames] = $this->extractGroupIds($policyItems);
if ($groupIds === []) {
return [
'code' => 'assignment_groups',
'severity' => 'safe',
'title' => 'Assignments',
'message' => 'No group-based assignments detected.',
'meta' => [
'group_count' => 0,
],
];
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$resolved = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
$orphaned = [];
foreach ($groupIds as $groupId) {
$group = $resolved[$groupId] ?? null;
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
continue;
}
$orphaned[] = [
'id' => $groupId,
'label' => $this->formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
];
}
if ($orphaned === []) {
return [
'code' => 'assignment_groups',
'severity' => 'safe',
'title' => 'Assignments',
'message' => sprintf('%d group assignment targets resolved.', count($groupIds)),
'meta' => [
'group_count' => count($groupIds),
'orphaned_count' => 0,
],
];
}
$unmapped = [];
$mapped = [];
$skipped = [];
foreach ($orphaned as $group) {
$groupId = $group['id'];
$mapping = $groupMapping[$groupId] ?? null;
if (! is_string($mapping) || $mapping === '') {
$unmapped[] = $group;
continue;
}
if ($mapping === 'SKIP') {
$skipped[] = $group;
continue;
}
$mapped[] = $group + [
'mapped_to' => $mapping,
];
}
$severity = $unmapped !== [] ? 'blocking' : 'warning';
$message = $unmapped !== []
? sprintf('%d group assignment targets are missing in the tenant and require mapping (or skip).', count($unmapped))
: sprintf('%d group assignment targets are missing in the tenant (mapped/skipped).', count($orphaned));
return [
'code' => 'assignment_groups',
'severity' => $severity,
'title' => 'Assignments',
'message' => $message,
'meta' => [
'group_count' => count($groupIds),
'orphaned_count' => count($orphaned),
'unmapped' => $unmapped,
'mapped' => $mapped,
'skipped' => $skipped,
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
{
$byType = [];
foreach ($policyItems as $item) {
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode !== 'preview-only') {
continue;
}
$label = $this->resolveTypeLabel($item->policy_type);
$byType[$label] ??= 0;
$byType[$label]++;
}
if ($byType === []) {
return [
'code' => 'preview_only',
'severity' => 'safe',
'title' => 'Preview-only types',
'message' => 'No preview-only policy types detected.',
'meta' => [
'count' => 0,
],
];
}
return [
'code' => 'preview_only',
'severity' => 'warning',
'title' => 'Preview-only types',
'message' => 'Some selected items are preview-only and will never execute.',
'meta' => [
'count' => array_sum($byType),
'types' => $byType,
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkMissingPolicies(Tenant $tenant, Collection $policyItems): ?array
{
$pairs = [];
foreach ($policyItems as $item) {
$identifier = $item->policy_identifier;
$type = $item->policy_type;
if (! is_string($identifier) || $identifier === '' || ! is_string($type) || $type === '') {
continue;
}
$pairs[] = [
'identifier' => $identifier,
'type' => $type,
'label' => $item->resolvedDisplayName(),
];
}
if ($pairs === []) {
return [
'code' => 'missing_policies',
'severity' => 'safe',
'title' => 'Target policies',
'message' => 'No policy identifiers available to verify.',
'meta' => [
'missing_count' => 0,
],
];
}
$identifiers = array_values(array_unique(array_column($pairs, 'identifier')));
$types = array_values(array_unique(array_column($pairs, 'type')));
$existing = Policy::query()
->where('tenant_id', $tenant->id)
->whereIn('external_id', $identifiers)
->whereIn('policy_type', $types)
->get(['id', 'external_id', 'policy_type'])
->mapWithKeys(fn (Policy $policy) => [$this->policyKey($policy->policy_type, $policy->external_id) => $policy->id])
->all();
$missing = [];
foreach ($pairs as $pair) {
$key = $this->policyKey($pair['type'], $pair['identifier']);
if (array_key_exists($key, $existing)) {
continue;
}
$missing[] = [
'type' => $pair['type'],
'identifier' => $pair['identifier'],
'label' => $pair['label'],
];
}
$missing = array_values(collect($missing)->unique(fn (array $row) => $this->policyKey($row['type'], $row['identifier']))->all());
if ($missing === []) {
return [
'code' => 'missing_policies',
'severity' => 'safe',
'title' => 'Target policies',
'message' => 'All policies exist in the tenant (restore will update).',
'meta' => [
'missing_count' => 0,
],
];
}
return [
'code' => 'missing_policies',
'severity' => 'warning',
'title' => 'Target policies',
'message' => sprintf('%d policies do not exist in the tenant and will be created.', count($missing)),
'meta' => [
'missing_count' => count($missing),
'missing' => $this->truncateList($missing, 10),
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkStalePolicies(Tenant $tenant, Collection $policyItems): ?array
{
$itemsByPolicyId = [];
foreach ($policyItems as $item) {
if (! $item->policy_id) {
continue;
}
$capturedAt = $item->captured_at;
if (! $capturedAt) {
continue;
}
$itemsByPolicyId[$item->policy_id][] = [
'backup_item_id' => $item->id,
'captured_at' => $capturedAt,
'label' => $item->resolvedDisplayName(),
];
}
if ($itemsByPolicyId === []) {
return [
'code' => 'stale_policies',
'severity' => 'safe',
'title' => 'Staleness',
'message' => 'No captured timestamps available to evaluate staleness.',
'meta' => [
'stale_count' => 0,
],
];
}
$latestVersions = PolicyVersion::query()
->where('tenant_id', $tenant->id)
->whereIn('policy_id', array_keys($itemsByPolicyId))
->selectRaw('policy_id, max(captured_at) as latest_captured_at')
->groupBy('policy_id')
->get()
->mapWithKeys(function (PolicyVersion $version) {
$latestCapturedAt = $version->getAttribute('latest_captured_at');
if (is_string($latestCapturedAt) && $latestCapturedAt !== '') {
$latestCapturedAt = CarbonImmutable::parse($latestCapturedAt);
} else {
$latestCapturedAt = null;
}
return [
(int) $version->policy_id => $latestCapturedAt,
];
})
->all();
$stale = [];
foreach ($itemsByPolicyId as $policyId => $policyItems) {
$latestCapturedAt = $latestVersions[(int) $policyId] ?? null;
if (! $latestCapturedAt) {
continue;
}
foreach ($policyItems as $policyItem) {
if ($latestCapturedAt->greaterThan($policyItem['captured_at'])) {
$stale[] = [
'backup_item_id' => $policyItem['backup_item_id'],
'label' => $policyItem['label'],
'snapshot_captured_at' => $policyItem['captured_at']->toIso8601String(),
'latest_captured_at' => $latestCapturedAt->toIso8601String(),
];
}
}
}
if ($stale === []) {
return [
'code' => 'stale_policies',
'severity' => 'safe',
'title' => 'Staleness',
'message' => 'No newer versions detected since the snapshot.',
'meta' => [
'stale_count' => 0,
],
];
}
return [
'code' => 'stale_policies',
'severity' => 'warning',
'title' => 'Staleness',
'message' => sprintf('%d policies have newer versions in the tenant than this snapshot.', count($stale)),
'meta' => [
'stale_count' => count($stale),
'stale' => $this->truncateList($stale, 10),
],
];
}
/**
* @param Collection<int, BackupItem> $items
* @param Collection<int, BackupItem> $policyItems
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
*/
private function checkMissingScopeTagsInScope(Collection $items, Collection $policyItems, bool $isSelectedScope): ?array
{
if (! $isSelectedScope) {
return [
'code' => 'scope_tags_in_scope',
'severity' => 'safe',
'title' => 'Scope tags',
'message' => 'Scope includes all items; foundations are available if present in the backup set.',
'meta' => [
'missing_scope_tags' => false,
],
];
}
$selectedScopeTagCount = $items->where('policy_type', 'roleScopeTag')->count();
$scopeTagIds = [];
foreach ($policyItems as $item) {
$ids = $item->scope_tag_ids;
if (! is_array($ids)) {
continue;
}
foreach ($ids as $id) {
if (! is_string($id) || $id === '' || $id === '0') {
continue;
}
$scopeTagIds[] = $id;
}
}
$scopeTagIds = array_values(array_unique($scopeTagIds));
if ($scopeTagIds === [] || $selectedScopeTagCount > 0) {
return [
'code' => 'scope_tags_in_scope',
'severity' => 'safe',
'title' => 'Scope tags',
'message' => 'Scope tags look OK for the selected items.',
'meta' => [
'missing_scope_tags' => false,
'referenced_scope_tags' => count($scopeTagIds),
'selected_scope_tag_items' => $selectedScopeTagCount,
],
];
}
return [
'code' => 'scope_tags_in_scope',
'severity' => 'warning',
'title' => 'Scope tags',
'message' => 'Policies reference scope tags, but scope tags are not included in the selected restore scope.',
'meta' => [
'missing_scope_tags' => true,
'referenced_scope_tags' => count($scopeTagIds),
'selected_scope_tag_items' => 0,
],
];
}
/**
* @param Collection<int, BackupItem> $policyItems
* @return array{0: array<int, string>, 1: array<string, string>}
*/
private function extractGroupIds(Collection $policyItems): array
{
$groupIds = [];
$sourceNames = [];
foreach ($policyItems as $item) {
if (! is_array($item->assignments) || $item->assignments === []) {
continue;
}
foreach ($item->assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (! in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true)) {
continue;
}
$groupId = $target['groupId'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$groupIds[] = $groupId;
$displayName = $target['group_display_name'] ?? null;
if (is_string($displayName) && $displayName !== '') {
$sourceNames[$groupId] = $displayName;
}
}
}
$groupIds = array_values(array_unique($groupIds));
return [$groupIds, $sourceNames];
}
private function formatGroupLabel(?string $name, string $id): string
{
$parts = [];
if (is_string($name) && $name !== '') {
$parts[] = $name;
}
$parts[] = Str::limit($id, 24, '...');
return implode(' • ', $parts);
}
private function policyKey(string $type, string $identifier): string
{
return $type.'|'.$identifier;
}
/**
* @return array<string, mixed>
*/
private function resolveTypeMeta(?string $type): array
{
if (! is_string($type) || $type === '') {
return [];
}
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
foreach ($types as $typeConfig) {
if (($typeConfig['type'] ?? null) === $type) {
return is_array($typeConfig) ? $typeConfig : [];
}
}
return [];
}
private function resolveRestoreMode(?string $policyType): string
{
$meta = $this->resolveTypeMeta($policyType);
return (string) ($meta['restore'] ?? 'enabled');
}
private function resolveTypeLabel(?string $policyType): string
{
$meta = $this->resolveTypeMeta($policyType);
return (string) ($meta['label'] ?? $policyType ?? 'Unknown');
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private function truncateList(array $items, int $limit): array
{
if (count($items) <= $limit) {
return $items;
}
return array_slice($items, 0, $limit);
}
}

View File

@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
{ {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$items = $this->loadItems($backupSet, $selectedItemIds); $items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
@ -180,6 +184,45 @@ public function executeFromPolicyVersion(
); );
} }
public function executeForRun(
RestoreRun $restoreRun,
Tenant $tenant,
BackupSet $backupSet,
?string $actorEmail = null,
?string $actorName = null,
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
if ($restoreRun->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
}
if ($restoreRun->backup_set_id !== $backupSet->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
}
if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) {
throw new \RuntimeException('Restore run is already finished.');
}
$selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null;
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
return $this->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: (bool) $restoreRun->is_dry_run,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $restoreRun->group_mapping ?? [],
existingRun: $restoreRun,
);
}
public function execute( public function execute(
Tenant $tenant, Tenant $tenant,
BackupSet $backupSet, BackupSet $backupSet,
@ -188,26 +231,65 @@ public function execute(
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
array $groupMapping = [], array $groupMapping = [],
?RestoreRun $existingRun = null,
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$items = $this->loadItems($backupSet, $selectedItemIds); $items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
$preview = $this->preview($tenant, $backupSet, $selectedItemIds); $preview = $this->preview($tenant, $backupSet, $selectedItemIds);
$restoreRun = RestoreRun::create([ $wizardMetadata = [
'tenant_id' => $tenant->id, 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'backup_set_id' => $backupSet->id, 'environment' => app()->environment('production') ? 'prod' : 'test',
'requested_by' => $actorEmail, 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
'is_dry_run' => $dryRun, ];
'status' => 'running',
'requested_items' => $selectedItemIds, if ($existingRun !== null) {
'preview' => $preview, if ($existingRun->tenant_id !== $tenant->id) {
'started_at' => CarbonImmutable::now(), throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
'metadata' => [], }
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); if ($existingRun->backup_set_id !== $backupSet->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
}
$metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []);
$existingRun->update([
'requested_by' => $existingRun->requested_by ?? $actorEmail,
'is_dry_run' => $dryRun,
'status' => 'running',
'requested_items' => $selectedItemIds,
'preview' => $preview,
'results' => null,
'failure_reason' => null,
'started_at' => $existingRun->started_at ?? CarbonImmutable::now(),
'completed_at' => null,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null),
]);
$restoreRun = $existingRun->refresh();
} else {
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => $dryRun,
'status' => 'running',
'requested_items' => $selectedItemIds,
'preview' => $preview,
'started_at' => CarbonImmutable::now(),
'metadata' => $wizardMetadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
}
if ($groupMapping !== []) { if ($groupMapping !== []) {
$this->auditLogger->log( $this->auditLogger->log(
@ -740,12 +822,12 @@ public function execute(
'status' => $status, 'status' => $status,
'results' => $results, 'results' => $results,
'completed_at' => CarbonImmutable::now(), 'completed_at' => CarbonImmutable::now(),
'metadata' => [ 'metadata' => array_merge($restoreRun->metadata ?? [], [
'failed' => $hardFailures, 'failed' => $hardFailures,
'non_applied' => $nonApplied, 'non_applied' => $nonApplied,
'total' => $totalCount, 'total' => $totalCount,
'foundations_skipped' => $foundationSkipped, 'foundations_skipped' => $foundationSkipped,
], ]),
]); ]);
$this->auditLogger->log( $this->auditLogger->log(

View File

@ -0,0 +1,73 @@
<?php
namespace App\Support;
enum RestoreRunStatus: string
{
case Draft = 'draft';
case Scoped = 'scoped';
case Checked = 'checked';
case Previewed = 'previewed';
case Pending = 'pending';
case Queued = 'queued';
case Running = 'running';
case Completed = 'completed';
case Partial = 'partial';
case Failed = 'failed';
case Cancelled = 'cancelled';
// Legacy / compatibility statuses (existing housekeeping semantics)
case Aborted = 'aborted';
case CompletedWithErrors = 'completed_with_errors';
public static function fromString(?string $value): ?self
{
if ($value === null) {
return null;
}
$normalized = strtolower(trim($value));
$normalized = str_replace([' ', '-'], '_', $normalized);
return self::tryFrom($normalized);
}
public function canTransitionTo(self $next): bool
{
if ($this === $next) {
return true;
}
return match ($this) {
self::Draft => in_array($next, [self::Scoped, self::Cancelled], true),
self::Scoped => in_array($next, [self::Checked, self::Cancelled], true),
self::Checked => in_array($next, [self::Previewed, self::Cancelled], true),
self::Previewed => in_array($next, [self::Queued, self::Cancelled], true),
self::Pending => in_array($next, [self::Queued, self::Running, self::Cancelled], true),
self::Queued => in_array($next, [self::Running, self::Cancelled], true),
self::Running => in_array($next, [self::Completed, self::Partial, self::Failed, self::Cancelled], true),
self::Completed,
self::Partial,
self::Failed,
self::Cancelled,
self::Aborted,
self::CompletedWithErrors => false,
};
}
public function isDeletable(): bool
{
return in_array($this, [
self::Draft,
self::Scoped,
self::Checked,
self::Previewed,
self::Completed,
self::Partial,
self::Failed,
self::Cancelled,
self::Aborted,
self::CompletedWithErrors,
], true);
}
}

View File

@ -0,0 +1,121 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$results = $getState() ?? [];
$results = is_array($results) ? $results : [];
$summary = $summary ?? [];
$summary = is_array($summary) ? $summary : [];
$blocking = (int) ($summary['blocking'] ?? 0);
$warning = (int) ($summary['warning'] ?? 0);
$safe = (int) ($summary['safe'] ?? 0);
$ranAt = $ranAt ?? null;
$ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') {
try {
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
} catch (\Throwable) {
$ranAtLabel = $ranAt;
}
}
$severityColor = static function (?string $severity): string {
return match ($severity) {
'blocking' => 'danger',
'warning' => 'warning',
default => 'success',
};
};
$limitedList = static function (array $items, int $limit = 5): array {
if (count($items) <= $limit) {
return $items;
}
return array_slice($items, 0, $limit);
};
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4">
<x-filament::section
heading="Safety checks"
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
{{ $blocking }} blocking
</x-filament::badge>
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
{{ $warning }} warnings
</x-filament::badge>
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
{{ $safe }} safe
</x-filament::badge>
</div>
</x-filament::section>
@if ($results === [])
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300">
No checks have been run yet.
</div>
</x-filament::section>
@else
<div class="space-y-3">
@foreach ($results as $result)
@php
$severity = is_array($result) ? ($result['severity'] ?? 'safe') : 'safe';
$title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check';
$message = is_array($result) ? ($result['message'] ?? null) : null;
$meta = is_array($result) ? ($result['meta'] ?? []) : [];
$meta = is_array($meta) ? $meta : [];
$unmappedGroups = $meta['unmapped'] ?? [];
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
@endphp
<x-filament::section>
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if (is_string($message) && $message !== '')
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<x-filament::badge :color="$severityColor($severity)" size="sm">
{{ ucfirst((string) $severity) }}
</x-filament::badge>
</div>
@if ($unmappedGroups !== [])
<div class="mt-3">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Unmapped groups
</div>
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@foreach ($unmappedGroups as $group)
@php
$label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null;
@endphp
@if (is_string($label) && $label !== '')
<li>{{ $label }}</li>
@endif
@endforeach
</ul>
</div>
@endif
</x-filament::section>
@endforeach
</div>
@endif
</div>
</x-dynamic-component>

View File

@ -0,0 +1,180 @@
@php
$fieldWrapperView = $getFieldWrapperView();
$diffs = $getState() ?? [];
$diffs = is_array($diffs) ? $diffs : [];
$summary = $summary ?? [];
$summary = is_array($summary) ? $summary : [];
$ranAt = $ranAt ?? null;
$ranAtLabel = null;
if (is_string($ranAt) && $ranAt !== '') {
try {
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
} catch (\Throwable) {
$ranAtLabel = $ranAt;
}
}
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0);
$scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0);
$diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0);
$limitedKeys = static function (array $items, int $limit = 8): array {
$keys = array_keys($items);
if (count($keys) <= $limit) {
return $keys;
}
return array_slice($keys, 0, $limit);
};
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4">
<x-filament::section
heading="Preview"
:description="$ranAtLabel ? ('Generated: ' . $ranAtLabel) : 'Generate a preview to see what would change.'"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$policiesChanged > 0 ? 'warning' : 'success'">
{{ $policiesChanged }}/{{ $policiesTotal }} policies changed
</x-filament::badge>
<x-filament::badge :color="$assignmentsChanged > 0 ? 'warning' : 'gray'">
{{ $assignmentsChanged }} assignments changed
</x-filament::badge>
<x-filament::badge :color="$scopeTagsChanged > 0 ? 'warning' : 'gray'">
{{ $scopeTagsChanged }} scope tags changed
</x-filament::badge>
@if ($diffsOmitted > 0)
<x-filament::badge color="gray">
{{ $diffsOmitted }} diffs omitted (limit)
</x-filament::badge>
@endif
</div>
</x-filament::section>
@if ($diffs === [])
<x-filament::section>
<div class="text-sm text-gray-600 dark:text-gray-300">
No preview generated yet.
</div>
</x-filament::section>
@else
<div class="space-y-3">
@foreach ($diffs as $entry)
@php
$entry = is_array($entry) ? $entry : [];
$name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item';
$type = $entry['policy_type'] ?? 'type';
$platform = $entry['platform'] ?? 'platform';
$action = $entry['action'] ?? 'update';
$diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : [];
$diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : [];
$added = (int) ($diffSummary['added'] ?? 0);
$removed = (int) ($diffSummary['removed'] ?? 0);
$changed = (int) ($diffSummary['changed'] ?? 0);
$assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false);
$scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false);
$diffOmitted = (bool) ($entry['diff_omitted'] ?? false);
$diffTruncated = (bool) ($entry['diff_truncated'] ?? false);
$changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []);
$addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []);
$removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []);
@endphp
<x-filament::section :heading="$name" :description="sprintf('%s • %s', $type, $platform)" collapsible :collapsed="true">
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$action === 'create' ? 'success' : 'gray'" size="sm">
{{ $action }}
</x-filament::badge>
<x-filament::badge color="success" size="sm">
{{ $added }} added
</x-filament::badge>
<x-filament::badge color="danger" size="sm">
{{ $removed }} removed
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ $changed }} changed
</x-filament::badge>
@if ($assignmentsDelta)
<x-filament::badge color="warning" size="sm">
assignments
</x-filament::badge>
@endif
@if ($scopeTagsDelta)
<x-filament::badge color="warning" size="sm">
scope tags
</x-filament::badge>
@endif
@if ($diffTruncated)
<x-filament::badge color="gray" size="sm">
truncated
</x-filament::badge>
@endif
</div>
@if ($diffOmitted)
<div class="mt-3 text-sm text-gray-600 dark:text-gray-300">
Diff details omitted due to preview limits. Narrow scope to see more items in detail.
</div>
@elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== [])
<div class="mt-3 space-y-3 text-sm text-gray-700 dark:text-gray-200">
@if ($changedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Changed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($changedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($addedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Added keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($addedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($removedKeys !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Removed keys (sample)
</div>
<ul class="mt-1 space-y-1">
@foreach ($removedKeys as $key)
<li class="rounded bg-gray-50 px-2 py-1 text-xs text-gray-800 dark:bg-white/5 dark:text-gray-200">
{{ $key }}
</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
</x-filament::section>
@endforeach
</div>
@endif
</div>
</x-dynamic-component>

View File

@ -0,0 +1,75 @@
# Implementation Plan: Restore Run Wizard (011)
**Branch**: `feat/011-restore-run-wizard` | **Date**: 2025-12-30
**Input**: Feature specification in `specs/011-restore-run-wizard/spec.md`
## Summary
Refactor Restore Run creation into a **Filament Wizard** that enforces **Safety First**:
source → scope → safety checks → preview → confirm + execute.
Leverage existing restore primitives (`RestoreService::preview()` / `RestoreService::execute()`) and incrementally introduce:
- structured **risk checks**
- **diff preview** artifacts/summaries
- stronger **execution gating** + audit fields
## Technical Context (current code)
- Filament Resource: `app/Filament/Resources/RestoreRunResource.php` (single form today)
- Restore engine: `app/Services/Intune/RestoreService.php` (preview + execute)
- Diff tools: `app/Services/Intune/PolicyNormalizer.php` + `app/Services/Intune/VersionDiff.php`
- Data model: `restore_runs` already stores `preview`, `results`, `metadata`, `requested_items`
## Phase 1 — Data + State Model (Wizard-ready)
- Define restore run lifecycle statuses (string enum values).
- Decide what is stored as dedicated columns vs `restore_runs.metadata` JSON.
- Add minimal persistence for wizard state:
- `scope_mode`, `check_summary`, `check_results`, `preview_summary`, `confirmed_at/by`, `environment`, `highlander_label`.
**Checkpoint**: RestoreRun can represent wizard progression and persist computations.
## Phase 2 — Filament Wizard UI (Create Restore Run)
- Replace the single Create form with a 5-step wizard UI.
- Implement step-level validation and state resets (changing backup set resets downstream).
- Keep dry-run default ON, and make execution UI unavailable until the wizard rules are satisfied.
**Checkpoint**: Wizard is usable end-to-end in dry-run.
## Phase 3 — Restore Scope Builder (Selection UX)
- Build grouped selection UI for BackupItems (type/platform), with search and “select all”.
- Clearly mark:
- foundations vs policies
- preview-only types
- items missing policy_version linkage / snapshot completeness hints (optional)
**Checkpoint**: Scoping is explicit, scalable, and safe.
## Phase 4 — Safety & Conflict Checks (RestoreRiskChecker)
- Implement server-side checks for the chosen scope.
- Persist results on the RestoreRun and display with severity badges.
- Block execution if blockers exist.
**Checkpoint**: Defensive layer in place; blockers stop execution.
## Phase 5 — Preview (RestoreDiffGenerator)
- Generate a diff summary (minimum) comparing backup snapshot vs current target state.
- Persist preview summary (and optionally per-item diffs with limits).
- Require preview completion before allowing execute.
**Checkpoint**: Preview step is a hard gate for execute and is auditable.
## Phase 6 — Confirm & Execute
- Add explicit confirmations:
- “I reviewed the impact”
- tenant hard-confirm (Highlander)
- environment badge (frozen at run creation)
- Execute restore via queue job (preferred) or synchronous execution (only if queue is out of scope for MVP).
- Update run statuses and persist outcomes.
**Checkpoint**: Execution is safe, gated, and traceable.
## Phase 7 — Tests + QA
- Pest feature tests for:
- wizard gating rules (execute disabled until conditions satisfied)
- safety checks persistence and blocking behavior
- preview summary generation
- Run targeted tests and Pint.

View File

@ -0,0 +1,224 @@
# Feature Specification: Restore Run Wizard (011)
**Feature Branch**: `feat/011-restore-run-wizard`
**Created**: 2025-12-30
**Status**: Draft
**Input**: Restore Run Wizard requirements (Safety First / Defensive Restore)
## Overview
Implement **Restore Runs** as a **multi-step Wizard** (instead of a single “Create Restore Run” form) to enforce **Safety First / Defensive Restore**.
Restore is a high-risk workflow. The wizard must guide admins through explicit checkpoints:
source selection → scoping → safety checks → preview → confirmation + execution.
## Problem Statement
The current Restore Run creation is a single form that can lead to:
- picking the wrong backup source
- restoring too broad a scope unintentionally
- executing without a structured “risk + preview + explicit confirmation” flow
## Goals
- Make restore a **deliberate, stepwise** process with strong defaults.
- Make **dry-run** the default, and keep “Execute” disabled until all safety gates are satisfied.
- Add **server-side safety/conflict checks** and persist results for auditability.
- Provide a **preview** (diff summary at minimum) before allowing execution.
## Non-Goals (v1)
- Approval workflows / multi-person approvals (but design must not block future addition).
- Perfect diff UX parity with Intune (basic normalized diff output is enough).
- A generic wizard framework (restore-specific implementation is fine).
---
## UX Principles
- **Dry-run default = ON**
- Wizard progression should slow the user down and force explicit decisions.
- “Execute” stays disabled until:
- Preview has been completed
- No blocking checks exist
- “I reviewed the impact” checkbox is checked
- Tenant hard-confirm matches (Highlander principle)
---
## Wizard Steps
### Step 1 — Select Backup Set (Source of Truth)
**Question:** “What are we restoring from?”
**Inputs**
- Backup Set (required)
**Read-only**
- Snapshot timestamp
- Tenant name
- Count of policies/items
- Types (Config / Security / Scripts …)
**Validation**
- `backup_set_id` is required
- Changing the backup set resets downstream state (scope, checks, preview, confirmation)
### Step 2 — Define Restore Scope (Selectivity)
**Question:** “What exactly should be restored?”
**Inputs**
- Scope mode: `all` (default) or `selected`
- If `selected`: item multiselect with search + select all
**UI**
- Prefer grouped by **type** and **platform**
- Mark “preview-only” types clearly
- Foundations should be discoverable (scope tags, assignment filters, notification templates)
**Notes**
- “Empty = all” only when scope mode is `all` (not when `selected`)
### Step 3 — Safety & Conflict Checks (Defensive Layer)
**Question:** “Is this dangerous?”
**Checks (server-side, persisted)**
- Target policy missing in target tenant?
- Target policy newer than backup? (staleness / overwrite risk)
- Assignments conflicts (e.g., mapping required / orphaned groups)
- Scope tag conflicts (mapping required / missing)
- Preview-only policies included in scope (should be warned and auto-dry-run)
**Severity**
- ❌ blocking
- ⚠️ warning
- ✅ safe
**Rules**
- Blocking checks prevent execution.
- Wizard may allow proceeding to preview, but must never allow execute while blockers exist.
### Step 4 — Preview (Dry-Run Simulation)
**Question:** “What would happen?”
**Outputs**
- Diff summary (at minimum):
- X policies changed
- Y assignments changed
- Z scope tags changed
- Per-item normalized diff (nice-to-have for v1, but plan for it)
**Defaults**
- “Preview only (Dry-run)” is ON by default
### Step 5 — Confirm & Execute (Point of No Return)
**Question:** “Do you really want to do this?”
**Confirmations**
- Checkbox: “I reviewed the impact”
- Tenant hard-confirm input (must match tenant display identifier)
- Environment badge (Prod/Test) highly visible (frozen at run start for audit)
**Rules**
- Execute disabled if:
- `dry_run = true`
- blockers exist
- tenant confirm mismatch
- acknowledgement unchecked
---
## Domain Model (v1-aligned)
We already have a `restore_runs` aggregate (`restore_runs` table) with:
- `backup_set_id`, `requested_items`, `preview`, `results`, `status`, `metadata`, timestamps, and `group_mapping`.
**v1 approach**
- Keep the existing primary key type (bigint) to avoid a disruptive migration.
- Extend the lifecycle/status semantics and persist wizard computations (checks + diff summaries) in structured fields:
- Prefer adding dedicated JSON columns only if needed; otherwise use `metadata` for wizard state.
### RestoreRun Lifecycle (proposed statuses)
`draft → scoped → checked → previewed → queued → running → completed|partial|failed|cancelled`
### Persisted Wizard State (minimum)
- `backup_set_id` (existing)
- `requested_items` (selected IDs, existing)
- `metadata.scope_mode` (`all|selected`)
- `metadata.environment` (`prod|test`)
- `metadata.highlander_label` (tenant identifier string, frozen)
- `metadata.check_summary` + `metadata.check_results` (Step 3)
- `metadata.preview_summary` + `metadata.preview_diffs` (Step 4; diffs may be truncated/limited)
- `metadata.confirmed_at`, `metadata.confirmed_by` (Step 5)
---
## Services / Responsibilities
- **RestoreScopeBuilder**: build selectable restore items (grouped, searchable), include foundations & mark preview-only.
- **RestoreRiskChecker**: run safety checks, return structured results + summary.
- **RestoreDiffGenerator**: generate diff summary (and optionally per-item diffs) for preview.
- **RestoreExecutor**: execute restore (idempotent, tenant/run locking), write detailed outcomes.
- **RestoreRunPolicy**: enforce invariants (no execution without preview + confirmations).
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Wizard-driven Restore Run (Priority: P1)
As an admin, I can create a restore run via a 5-step wizard and I cannot accidentally execute without preview + explicit confirmations.
**Why this priority**: This is the safety foundation; without it, restore remains risky UX.
**Independent Test**: In Filament, create a restore run with dry-run, see checks + preview, and confirm execute stays disabled until gates satisfied.
**Acceptance Scenarios**
1. **Given** I select a backup set, **When** I move to the next step, **Then** scope/check/preview state is reset when I change the backup set again.
2. **Given** I keep dry-run enabled, **When** I reach Step 5, **Then** Execute is disabled.
3. **Given** I disable dry-run, **When** I have not completed preview, **Then** Execute is disabled.
---
### User Story 2 — Safety Checks block execution (Priority: P1)
As an admin, I see blocking vs warning checks, and execution is blocked when blockers exist.
**Why this priority**: Defensive restore requires an explicit risk layer.
**Independent Test**: Create a scope that triggers a blocking check and verify execution cannot proceed.
**Acceptance Scenarios**
1. **Given** a blocking check exists, **When** I reach Step 5, **Then** Execute remains disabled and blockers are visible.
2. **Given** only warnings exist, **When** I acknowledge impact and hard-confirm tenant, **Then** I can execute (dry-run off).
---
### User Story 3 — Preview diff summary (Priority: P2)
As an admin, I can preview what would change before executing restore.
**Why this priority**: A restore without preview is operationally unsafe.
**Independent Test**: Run Step 4 preview and verify diff summary is computed and persisted on the RestoreRun.
**Acceptance Scenarios**
1. **Given** I scoped items, **When** I run preview, **Then** I see a summary (changed policies count) and it persists on the restore run.
---
## Edge Cases
- Very large backup sets (hundreds/thousands of items): selection/search must remain responsive.
- Switching backup set mid-flow resets downstream state safely.
- Policies not present in target tenant: shown as warning/blocker depending on restore mode.
- RBAC-limited tenant setup: checks must clearly show “inventory/restore may be partial”.
---
## Functional Requirements
- **FR-011.1**: System MUST implement Restore Run creation as a 5-step wizard in Filament.
- **FR-011.2**: System MUST default `dry_run = true` and prevent execution while dry-run is enabled.
- **FR-011.3**: System MUST run server-side safety checks and persist results (summary + details) for audit.
- **FR-011.4**: System MUST generate at least a diff summary on preview and persist it.
- **FR-011.5**: System MUST require explicit acknowledgement + tenant hard-confirm before allowing execution.
- **FR-011.6**: System MUST freeze environment badge and tenant label for audit on run creation.
- **FR-011.7**: System MUST keep execution disabled if any blocking checks exist.
- **FR-011.8**: System MUST record execution outcomes and leave an auditable trail (existing audit log patterns).
---
## Success Criteria
- **SC-011.1**: Admins can only execute after preview + confirmations; no accidental execution path exists.
- **SC-011.2**: Blocking checks reliably prevent execution.
- **SC-011.3**: Preview produces a persisted summary for every run.

View File

@ -0,0 +1,45 @@
# Tasks: Restore Run Wizard (011)
**Branch**: `feat/011-restore-run-wizard` | **Date**: 2025-12-30
**Input**: `specs/011-restore-run-wizard/spec.md`, `specs/011-restore-run-wizard/plan.md`
## Phase 0 — Specs (this PR)
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
## Phase 1 — Data Model + Status Semantics
- [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed).
- [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required).
- [x] T004 Freeze `environment` + `highlander_label` at run creation for audit.
## Phase 2 — Filament Wizard (Create Restore Run)
- [x] T005 Replace current single-form create with a 5-step wizard (Step 15 as in spec).
- [x] T006 Ensure changing `backup_set_id` resets downstream wizard state.
- [x] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
## Phase 3 — Restore Scope UX
- [x] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle.
- [x] T009 Mark preview-only types clearly and ensure they never execute.
- [x] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates).
## Phase 4 — Safety & Conflict Checks
- [x] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`.
- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
## Phase 5 — Preview (Diff)
- [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
- [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute.
## Phase 6 — Confirm & Execute
- [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm).
- [x] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps.
- [x] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish.
## Phase 7 — Tests + Formatting
- [x] T018 Add Pest tests for wizard gating rules and status transitions.
- [x] T019 Add Pest tests for safety checks persistence and blocking behavior.
- [x] T020 Add Pest tests for preview summary generation.
- [x] T021 Run `./vendor/bin/pint --dirty`.
- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).
## Phase 8 — Policy Version Entry Point (later)
- [x] T023 Add a “Restore via Wizard” action on `PolicyVersion` that creates a 1-item Backup Set (source = policy_version) and opens the Restore Run wizard prefilled/scoped to that item.

View File

@ -0,0 +1,68 @@
<?php
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreService;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
test('execute restore run job moves queued to running and calls the executor', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 0,
]);
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => 'actor@example.com',
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'requested_items' => null,
'preview' => [],
'results' => null,
'metadata' => [],
]);
$restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($tenant, $backupSet) {
$mock->shouldReceive('executeForRun')
->once()
->withArgs(function (RestoreRun $run, Tenant $runTenant, BackupSet $runBackupSet, ?string $email, ?string $name) use ($tenant, $backupSet): bool {
return $run->status === RestoreRunStatus::Running->value
&& $runTenant->is($tenant)
&& $runBackupSet->is($backupSet)
&& $email === 'actor@example.com'
&& $name === 'Actor';
})
->andReturnUsing(function (RestoreRun $run): RestoreRun {
$run->update([
'status' => RestoreRunStatus::Completed->value,
'completed_at' => now(),
]);
return $run->refresh();
});
});
$job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor');
$job->handle($restoreService, app(AuditLogger::class));
$restoreRun->refresh();
expect($restoreRun->started_at)->not->toBeNull();
expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value);
});

View File

@ -0,0 +1,164 @@
<?php
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GroupResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
test('policy version can open restore wizard via row action', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-wizard',
'name' => 'Tenant',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'platform' => 'windows',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 3,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
'assignments' => [['intent' => 'apply']],
'scope_tags' => [
'ids' => ['st-1'],
'names' => ['Tag 1'],
],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
Livewire::test(ListPolicyVersions::class)
->callTableAction('restore_via_wizard', $version)
->assertRedirectContains(RestoreRunResource::getUrl('create', [], false));
$backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first();
expect($backupSet)->not->toBeNull();
expect($backupSet->tenant_id)->toBe($tenant->id);
expect($backupSet->metadata['policy_version_id'] ?? null)->toBe($version->id);
$backupItem = BackupItem::query()->where('backup_set_id', $backupSet->id)->first();
expect($backupItem)->not->toBeNull();
expect($backupItem->policy_version_id)->toBe($version->id);
expect($backupItem->policy_identifier)->toBe($policy->external_id);
expect($backupItem->metadata['scope_tag_ids'] ?? null)->toBe(['st-1']);
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']);
});
test('restore run wizard can be prefilled from query params for policy version backup set', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-prefill',
'name' => 'Tenant',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-2',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'platform' => 'windows',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'snapshot' => ['id' => $policy->external_id],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
'group_display_name' => 'Source Group',
],
'intent' => 'apply',
]],
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Policy Version Restore',
'status' => 'completed',
'item_count' => 1,
'completed_at' => now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $version->id,
],
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_version_id' => $version->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => $version->snapshot ?? [],
'assignments' => $version->assignments,
]);
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturnUsing(function (array $groupIds): array {
return collect($groupIds)
->mapWithKeys(fn (string $id) => [$id => [
'id' => $id,
'displayName' => null,
'orphaned' => true,
]])
->all();
});
});
$user = User::factory()->create();
$this->actingAs($user);
$component = Livewire::withQueryParams([
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])->test(CreateRestoreRun::class);
expect($component->get('data.backup_set_id'))->toBe($backupSet->id);
expect($component->get('data.scope_mode'))->toBe('selected');
expect($component->get('data.backup_item_ids'))->toBe([$backupItem->id]);
$mapping = $component->get('data.group_mapping');
expect($mapping)->toBeArray();
expect(array_key_exists('source-group-1', $mapping))->toBeTrue();
expect($mapping['source-group-1'])->toBeNull();
$component
->goToNextWizardStep()
->assertFormFieldVisible('group_mapping.source-group-1');
});

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun; use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
@ -11,7 +12,7 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('restore selection shows readable labels and descriptions', function () { test('restore selection options are grouped and filter ignored policies', function () {
$tenant = Tenant::factory()->create(['status' => 'active']); $tenant = Tenant::factory()->create(['status' => 'active']);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -22,6 +23,13 @@
'display_name' => 'Policy Display', 'display_name' => 'Policy Display',
'platform' => 'windows', 'platform' => 'windows',
]); ]);
$previewOnlyPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-preview-only',
'policy_type' => 'conditionalAccessPolicy',
'display_name' => 'Conditional Access Policy',
'platform' => 'all',
]);
$ignoredPolicy = Policy::factory()->create([ $ignoredPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'external_id' => 'policy-ignored', 'external_id' => 'policy-ignored',
@ -32,10 +40,10 @@
]); ]);
$backupSet = BackupSet::factory()->for($tenant)->create([ $backupSet = BackupSet::factory()->for($tenant)->create([
'item_count' => 2, 'item_count' => 4,
]); ]);
BackupItem::factory() $policyItem = BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
->state([ ->state([
@ -47,7 +55,7 @@
]) ])
->create(); ->create();
BackupItem::factory() $ignoredPolicyItem = BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
->state([ ->state([
@ -59,7 +67,7 @@
]) ])
->create(); ->create();
BackupItem::factory() $scopeTagItem = BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
->state([ ->state([
@ -77,6 +85,18 @@
]) ])
->create(); ->create();
$previewOnlyItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => $previewOnlyPolicy->id,
'policy_identifier' => $previewOnlyPolicy->external_id,
'policy_type' => $previewOnlyPolicy->policy_type,
'platform' => $previewOnlyPolicy->platform,
'payload' => ['id' => $previewOnlyPolicy->external_id],
])
->create();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
@ -84,13 +104,33 @@
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
]) ])
->assertSee('Policy Display') ->goToNextWizardStep()
->assertDontSee('Ignored Policy') ->fillForm([
->assertSee('Scope Tag Alpha') 'scope_mode' => 'selected',
->assertSee('Settings Catalog Policy') ])
->assertSee('Scope Tag') ->assertFormFieldVisible('backup_item_ids')
->assertSee('restore: enabled')
->assertSee('id: policy-1')
->assertSee('id: tag-1')
->assertSee('Include foundations'); ->assertSee('Include foundations');
$method = new ReflectionMethod(RestoreRunResource::class, 'restoreItemGroupedOptions');
$method->setAccessible(true);
$groupedOptions = $method->invoke(null, $backupSet->id);
expect($groupedOptions)->toHaveKey('Configuration • Settings Catalog Policy • windows');
expect($groupedOptions)->toHaveKey('Foundations • Scope Tag • all');
expect($groupedOptions)->toHaveKey('Conditional Access • Conditional Access • all • preview-only');
$flattenedOptions = collect($groupedOptions)
->reduce(fn (array $carry, array $options): array => $carry + $options, []);
expect($flattenedOptions)->toHaveKey($policyItem->id);
expect($flattenedOptions[$policyItem->id])->toBe('Policy Display');
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
expect($flattenedOptions[$scopeTagItem->id])->toBe('Scope Tag Alpha');
expect($flattenedOptions)->toHaveKey($previewOnlyItem->id);
expect($flattenedOptions[$previewOnlyItem->id])->toBe('Conditional Access Policy');
}); });

View File

@ -77,12 +77,23 @@
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
Livewire::test(CreateRestoreRun::class) $component = Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'backup_item_ids' => [$backupItem->id],
]) ])
->assertFormFieldVisible('group_mapping.source-group-1'); ->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]);
$mapping = $component->get('data.group_mapping');
expect($mapping)->toBeArray();
expect(array_key_exists('source-group-1', $mapping))->toBeTrue();
expect($mapping['source-group-1'])->toBeNull();
$component->assertFormFieldVisible('group_mapping.source-group-1');
}); });
test('restore wizard persists group mapping selections', function () { test('restore wizard persists group mapping selections', function () {
@ -150,12 +161,19 @@
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id], 'backup_item_ids' => [$backupItem->id],
'group_mapping' => [ 'group_mapping' => [
'source-group-1' => 'target-group-1', 'source-group-1' => 'target-group-1',
], ],
'is_dry_run' => true,
]) ])
->goToNextWizardStep()
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();

View File

@ -0,0 +1,132 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore wizard generates a normalized preview diff summary and persists it', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now()->subDay(),
'snapshot' => [
'foo' => 'current',
],
'metadata' => [],
'assignments' => [],
'scope_tags' => [
'ids' => ['tag-2'],
'names' => ['Tag Two'],
],
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'payload' => [
'foo' => 'backup',
],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-1',
],
'intent' => 'apply',
]],
'metadata' => [
'scope_tag_ids' => ['tag-1'],
'scope_tag_names' => ['Tag One'],
],
]);
$user = User::factory()->create();
$this->actingAs($user);
$component = Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->goToNextWizardStep()
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview');
$summary = $component->get('data.preview_summary');
$diffs = $component->get('data.preview_diffs');
expect($summary)->toBeArray();
expect($summary['policies_total'] ?? null)->toBe(1);
expect($summary['policies_changed'] ?? null)->toBe(1);
expect($summary['assignments_changed'] ?? null)->toBe(1);
expect($summary['scope_tags_changed'] ?? null)->toBe(1);
expect($diffs)->toBeArray();
expect($diffs)->not->toBeEmpty();
$first = $diffs[0] ?? [];
expect($first)->toBeArray();
expect($first['action'] ?? null)->toBe('update');
expect($first['assignments_changed'] ?? null)->toBeTrue();
expect($first['scope_tags_changed'] ?? null)->toBeTrue();
expect($first['diff']['summary']['changed'] ?? null)->toBe(1);
$component
->goToNextWizardStep()
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::query()->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->metadata)->toHaveKeys([
'preview_summary',
'preview_diffs',
'preview_ran_at',
]);
expect($run->metadata['preview_summary']['policies_changed'] ?? null)->toBe(1);
});

View File

@ -0,0 +1,222 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
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\Services\Graph\GroupResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore wizard can run safety checks and persists results on the restore run', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'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,
'captured_at' => now(),
'payload' => ['id' => $policy->external_id],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
'group_display_name' => 'Source Group',
],
'intent' => 'apply',
]],
]);
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturnUsing(function (array $groupIds): array {
return collect($groupIds)
->mapWithKeys(fn (string $id) => [$id => [
'id' => $id,
'displayName' => null,
'orphaned' => true,
]])
->all();
});
});
$user = User::factory()->create();
$this->actingAs($user);
$component = Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->assertFormComponentActionVisible('check_results', 'run_restore_checks')
->callFormComponentAction('check_results', 'run_restore_checks');
$summary = $component->get('data.check_summary');
$results = $component->get('data.check_results');
expect($summary)->toBeArray();
expect($summary['blocking'] ?? null)->toBe(1);
expect($summary['has_blockers'] ?? null)->toBeTrue();
expect($results)->toBeArray();
expect($results)->not->toBeEmpty();
$assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups');
expect($assignmentCheck)->toBeArray();
expect($assignmentCheck['severity'] ?? null)->toBe('blocking');
$unmappedGroups = $assignmentCheck['meta']['unmapped'] ?? [];
expect($unmappedGroups)->toBeArray();
expect($unmappedGroups[0]['id'] ?? null)->toBe('source-group-1');
$checksRanAt = $component->get('data.checks_ran_at');
expect($checksRanAt)->toBeString();
$component
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::query()->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->metadata)->toHaveKeys([
'check_summary',
'check_results',
'checks_ran_at',
]);
expect($run->metadata['check_summary']['blocking'] ?? null)->toBe(1);
});
test('restore wizard treats skipped orphaned groups as a warning instead of a blocker', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'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,
'captured_at' => now(),
'payload' => ['id' => $policy->external_id],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'source-group-1',
'group_display_name' => 'Source Group',
],
'intent' => 'apply',
]],
]);
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->andReturnUsing(function (array $groupIds): array {
return collect($groupIds)
->mapWithKeys(fn (string $id) => [$id => [
'id' => $id,
'displayName' => null,
'orphaned' => true,
]])
->all();
});
});
$user = User::factory()->create();
$this->actingAs($user);
$component = Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->set('data.group_mapping', (object) [
'source-group-1' => 'SKIP',
])
->callFormComponentAction('check_results', 'run_restore_checks');
$summary = $component->get('data.check_summary');
$results = $component->get('data.check_results');
expect($summary)->toBeArray();
expect($summary['blocking'] ?? null)->toBe(0);
expect($summary['has_blockers'] ?? null)->toBeFalse();
expect($summary['warning'] ?? null)->toBe(1);
$assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups');
expect($assignmentCheck)->toBeArray();
expect($assignmentCheck['severity'] ?? null)->toBe('warning');
$skippedGroups = $assignmentCheck['meta']['skipped'] ?? [];
expect($skippedGroups)->toBeArray();
expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1');
});

View File

@ -0,0 +1,165 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\RestoreRunStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore run wizard blocks execution when confirmations are missing', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => [
'displayName' => 'Backup Policy',
],
]);
$user = User::factory()->create([
'email' => 'tester@example.com',
'name' => 'Tester',
]);
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
])
->call('create')
->assertHasFormErrors(['acknowledged_impact', 'tenant_confirm']);
expect(RestoreRun::count())->toBe(0);
});
test('restore run wizard queues execution when gates are satisfied', function () {
Bus::fake();
$tenant = Tenant::create([
'tenant_id' => 'tenant-2',
'name' => 'Tenant Two',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-2',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => [
'displayName' => 'Backup Policy',
],
]);
$user = User::factory()->create([
'email' => 'executor@example.com',
'name' => 'Executor',
]);
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Tenant Two',
])
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::query()->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->status)->toBe(RestoreRunStatus::Queued->value);
expect($run->is_dry_run)->toBeFalse();
expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com');
expect($run->metadata['confirmed_at'] ?? null)->toBeString();
Bus::assertDispatched(ExecuteRestoreRunJob::class);
});

View File

@ -0,0 +1,86 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore run stores wizard audit metadata and preserves it on completion', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$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' => null,
'policy_identifier' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'payload' => ['id' => 'policy-1'],
'metadata' => [
'displayName' => 'Backup Policy One',
],
]);
$user = User::factory()->create([
'email' => 'tester@example.com',
'name' => 'Tester',
]);
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::query()->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->metadata)->toHaveKeys([
'scope_mode',
'environment',
'highlander_label',
'failed',
'non_applied',
'total',
'foundations_skipped',
]);
expect($run->metadata['scope_mode'])->toBe('selected');
expect($run->metadata['environment'])->toBe('test');
expect($run->metadata['highlander_label'])->toBe('Tenant One');
});