diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index bb3e19d..4bab649 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -6,6 +6,8 @@ use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionRestoreJob; +use App\Models\BackupItem; +use App\Models\BackupSet; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\BulkOperationService; @@ -13,6 +15,7 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; use BackedEnum; +use Carbon\CarbonImmutable; use Filament\Actions; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; @@ -183,6 +186,96 @@ public static function table(Table $table): Table ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), 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') ->label('Archive') ->color('danger') diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 3f7088d..ff2500b 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -6,6 +6,7 @@ use App\Jobs\BulkRestoreRunDeleteJob; use App\Jobs\BulkRestoreRunForceDeleteJob; use App\Jobs\BulkRestoreRunRestoreJob; +use App\Jobs\ExecuteRestoreRunJob; use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\RestoreRun; @@ -13,7 +14,11 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\AuditLogger; +use App\Services\Intune\RestoreDiffGenerator; +use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; +use App\Support\RestoreRunStatus; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -26,13 +31,16 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; +use Filament\Schemas\Components\Wizard\Step; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use UnitEnum; class RestoreRunResource extends Resource @@ -69,8 +77,10 @@ public static function form(Schema $schema): Schema }) ->reactive() ->afterStateUpdated(function (Set $set): void { - $set('backup_item_ids', []); + $set('scope_mode', 'all'); + $set('backup_item_ids', null); $set('group_mapping', []); + $set('is_dry_run', true); }) ->required(), Forms\Components\CheckboxList::make('backup_item_ids') @@ -137,6 +147,491 @@ public static function form(Schema $schema): Schema ]); } + /** + * @return array + */ + 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 { return $table @@ -533,11 +1028,72 @@ private static function restoreItemOptionData(?int $backupSetId): array ]; } - static $cache = []; - $cacheKey = $tenant->getKey().':'.$backupSetId; + $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId); - if (isset($cache[$cacheKey])) { - return $cache[$cacheKey]; + return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array { + $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> + */ + private static function restoreItemGroupedOptions(?int $backupSetId): array + { + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return []; } $items = BackupItem::query() @@ -548,49 +1104,40 @@ private static function restoreItemOptionData(?int $backupSetId): array ->orWhereDoesntHave('policy') ->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() ->sortBy(function (BackupItem $item) { $meta = static::typeMeta($item->policy_type); $category = $meta['category'] ?? 'Policies'; $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; + $typeLabel = $meta['label'] ?? $item->policy_type; + $platform = $item->platform ?? $meta['platform'] ?? null; $name = strtolower($item->resolvedDisplayName()); - return strtolower($categoryKey.'-'.$name); + return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name); }); - $options = []; - $descriptions = []; + $groups = []; 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; + $platform = $item->platform ?? $meta['platform'] ?? 'all'; + $restoreMode = $meta['restore'] ?? 'enabled'; - $options[$item->id] = $displayName; - - $parts = array_filter([ + $groupLabel = implode(' • ', 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, - ]); + $restoreMode === 'preview-only' ? 'preview-only' : null, + ])); - $descriptions[$item->id] = implode(' • ', $parts); + $groups[$groupLabel] ??= []; + $groups[$groupLabel][$item->id] = $item->resolvedDisplayName(); } - return $cache[$cacheKey] = [ - 'options' => $options, - 'descriptions' => $descriptions, - ]; + return $groups; } public static function createRestoreRun(array $data): RestoreRun @@ -608,15 +1155,170 @@ public static function createRestoreRun(array $data): RestoreRun /** @var RestoreService $service */ $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, - backupSet: $backupSet, - selectedItemIds: $data['backup_item_ids'] ?? null, - dryRun: (bool) ($data['is_dry_run'] ?? true), - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - groupMapping: $data['group_mapping'] ?? [], + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', ); + + ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); + + return $restoreRun->refresh(); } /** @@ -703,6 +1405,111 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return $unresolved; } + /** + * @param array|null $selectedItemIds + * @return array + */ + 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 + */ + 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 */ diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2c22eb1..2e4e6de 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,13 +3,118 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; 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 Illuminate\Database\Eloquent\Model; class CreateRestoreRun extends CreateRecord { + use HasWizard; + 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 + */ + 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 { return RestoreRunResource::createRestoreRun($data); diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php new file mode 100644 index 0000000..dd57d20 --- /dev/null +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -0,0 +1,134 @@ +find($this->restoreRunId); + + if (! $restoreRun) { + return; + } + + if ($restoreRun->status !== RestoreRunStatus::Queued->value) { + return; + } + + $tenant = $restoreRun->tenant; + $backupSet = $restoreRun->backupSet; + + if (! $tenant || ! $backupSet || $backupSet->trashed()) { + $restoreRun->update([ + 'status' => RestoreRunStatus::Failed->value, + 'failure_reason' => 'Backup set is archived or unavailable.', + 'completed_at' => CarbonImmutable::now(), + ]); + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'restore.failed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $restoreRun->backup_set_id, + 'reason' => 'Backup set is archived or unavailable.', + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'failed', + ); + } + + return; + } + + $restoreRun->update([ + 'status' => RestoreRunStatus::Running->value, + 'started_at' => CarbonImmutable::now(), + 'failure_reason' => null, + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'restore.started', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', + ); + + try { + $restoreService->executeForRun( + restoreRun: $restoreRun, + tenant: $tenant, + backupSet: $backupSet, + actorEmail: $this->actorEmail, + actorName: $this->actorName, + ); + } catch (Throwable $throwable) { + $restoreRun->refresh(); + + if ($restoreRun->status === RestoreRunStatus::Running->value) { + $restoreRun->update([ + 'status' => RestoreRunStatus::Failed->value, + 'failure_reason' => $throwable->getMessage(), + 'completed_at' => CarbonImmutable::now(), + ]); + } + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'restore.failed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'reason' => $throwable->getMessage(), + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'failed', + ); + } + + throw $throwable; + } + } +} diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 28945c4..e4d3ce0 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\RestoreRunStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -35,17 +37,30 @@ public function backupSet(): BelongsTo 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 { - $status = strtolower(trim((string) $this->status)); - $status = str_replace([' ', '-'], '_', $status); + $status = RestoreRunStatus::fromString($this->status); - return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); + return $status?->isDeletable() ?? false; } // Group mapping helpers diff --git a/app/Services/Intune/RestoreDiffGenerator.php b/app/Services/Intune/RestoreDiffGenerator.php new file mode 100644 index 0000000..200eb65 --- /dev/null +++ b/app/Services/Intune/RestoreDiffGenerator.php @@ -0,0 +1,248 @@ +|null $selectedItemIds + * @return array{summary: array, diffs: array>} + */ + 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|null $selectedItemIds + * @return Collection + */ + 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 $policyIds + * @return array + */ + 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> + */ + 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; + } +} diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php new file mode 100644 index 0000000..96ff30c --- /dev/null +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -0,0 +1,608 @@ +|null $selectedItemIds + * @param array $groupMapping + * @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array}>} + */ + 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|null $selectedItemIds + * @return Collection + */ + 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 $policyItems + * @param array $groupMapping + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $items + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{0: array, 1: array} + */ + 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 + */ + 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> $items + * @return array> + */ + private function truncateList(array $items, int $limit): array + { + if (count($items) <= $limit) { + return $items; + } + + return array_slice($items, 0, $limit); + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index d5082b1..e9e02d1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $items = $this->loadItems($backupSet, $selectedItemIds); [$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( Tenant $tenant, BackupSet $backupSet, @@ -188,26 +231,65 @@ public function execute( ?string $actorEmail = null, ?string $actorName = null, array $groupMapping = [], + ?RestoreRun $existingRun = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); - $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' => [], - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); + $wizardMetadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()), + ]; + + if ($existingRun !== null) { + if ($existingRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($existingRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + $metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []); + + $existingRun->update([ + 'requested_by' => $existingRun->requested_by ?? $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'results' => null, + 'failure_reason' => null, + 'started_at' => $existingRun->started_at ?? CarbonImmutable::now(), + 'completed_at' => null, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null), + ]); + + $restoreRun = $existingRun->refresh(); + } else { + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'started_at' => CarbonImmutable::now(), + 'metadata' => $wizardMetadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } if ($groupMapping !== []) { $this->auditLogger->log( @@ -740,12 +822,12 @@ public function execute( 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), - 'metadata' => [ + 'metadata' => array_merge($restoreRun->metadata ?? [], [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, 'total' => $totalCount, 'foundations_skipped' => $foundationSkipped, - ], + ]), ]); $this->auditLogger->log( diff --git a/app/Support/RestoreRunStatus.php b/app/Support/RestoreRunStatus.php new file mode 100644 index 0000000..727c4e5 --- /dev/null +++ b/app/Support/RestoreRunStatus.php @@ -0,0 +1,73 @@ + 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); + } +} diff --git a/resources/views/filament/forms/components/restore-run-checks.blade.php b/resources/views/filament/forms/components/restore-run-checks.blade.php new file mode 100644 index 0000000..e7469ba --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -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 + + +
+ +
+ + {{ $blocking }} blocking + + + {{ $warning }} warnings + + + {{ $safe }} safe + +
+
+ + @if ($results === []) + +
+ No checks have been run yet. +
+
+ @else +
+ @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 + + +
+
+
+ {{ $title }} +
+ @if (is_string($message) && $message !== '') +
+ {{ $message }} +
+ @endif +
+ + + {{ ucfirst((string) $severity) }} + +
+ + @if ($unmappedGroups !== []) +
+
+ Unmapped groups +
+
    + @foreach ($unmappedGroups as $group) + @php + $label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null; + @endphp + @if (is_string($label) && $label !== '') +
  • {{ $label }}
  • + @endif + @endforeach +
+
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/forms/components/restore-run-preview.blade.php b/resources/views/filament/forms/components/restore-run-preview.blade.php new file mode 100644 index 0000000..f92e4b1 --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-preview.blade.php @@ -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 + + +
+ +
+ + {{ $policiesChanged }}/{{ $policiesTotal }} policies changed + + + {{ $assignmentsChanged }} assignments changed + + + {{ $scopeTagsChanged }} scope tags changed + + @if ($diffsOmitted > 0) + + {{ $diffsOmitted }} diffs omitted (limit) + + @endif +
+
+ + @if ($diffs === []) + +
+ No preview generated yet. +
+
+ @else +
+ @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 + + +
+ + {{ $action }} + + + {{ $added }} added + + + {{ $removed }} removed + + + {{ $changed }} changed + + @if ($assignmentsDelta) + + assignments + + @endif + @if ($scopeTagsDelta) + + scope tags + + @endif + @if ($diffTruncated) + + truncated + + @endif +
+ + @if ($diffOmitted) +
+ Diff details omitted due to preview limits. Narrow scope to see more items in detail. +
+ @elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== []) +
+ @if ($changedKeys !== []) +
+
+ Changed keys (sample) +
+
    + @foreach ($changedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($addedKeys !== []) +
+
+ Added keys (sample) +
+
    + @foreach ($addedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($removedKeys !== []) +
+
+ Removed keys (sample) +
+
    + @foreach ($removedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif +
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 323e85b..e6c7b48 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -7,37 +7,39 @@ ## Phase 0 — Specs (this PR) - [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. ## Phase 1 — Data Model + Status Semantics -- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). -- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). -- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit. +- [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) -- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). -- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. -- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. +- [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 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 -- [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. -- [ ] T009 Mark preview-only types clearly and ensure they never execute. -- [ ] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). +- [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 -- [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`. -- [ ] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist. +- [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) -- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. -- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. +- [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 -- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). -- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. -- [ ] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. +- [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). +- [x] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. +- [x] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. ## Phase 7 — Tests + Formatting -- [ ] T018 Add Pest tests for wizard gating rules and status transitions. -- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. -- [ ] T020 Add Pest tests for preview summary generation. -- [ ] T021 Run `./vendor/bin/pint --dirty`. -- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). +- [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. \ No newline at end of file diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php new file mode 100644 index 0000000..9fb258a --- /dev/null +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -0,0 +1,68 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => 'actor@example.com', + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => null, + 'preview' => [], + 'results' => null, + 'metadata' => [], + ]); + + $restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($tenant, $backupSet) { + $mock->shouldReceive('executeForRun') + ->once() + ->withArgs(function (RestoreRun $run, Tenant $runTenant, BackupSet $runBackupSet, ?string $email, ?string $name) use ($tenant, $backupSet): bool { + return $run->status === RestoreRunStatus::Running->value + && $runTenant->is($tenant) + && $runBackupSet->is($backupSet) + && $email === 'actor@example.com' + && $name === 'Actor'; + }) + ->andReturnUsing(function (RestoreRun $run): RestoreRun { + $run->update([ + 'status' => RestoreRunStatus::Completed->value, + 'completed_at' => now(), + ]); + + return $run->refresh(); + }); + }); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $job->handle($restoreService, app(AuditLogger::class)); + + $restoreRun->refresh(); + + expect($restoreRun->started_at)->not->toBeNull(); + expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value); +}); diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php new file mode 100644 index 0000000..7a84965 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -0,0 +1,164 @@ + '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'); +}); diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index a3ce170..7ac2911 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -1,5 +1,6 @@ create(['status' => 'active']); $tenant->makeCurrent(); @@ -22,6 +23,13 @@ 'display_name' => 'Policy Display', '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([ 'tenant_id' => $tenant->id, 'external_id' => 'policy-ignored', @@ -32,10 +40,10 @@ ]); $backupSet = BackupSet::factory()->for($tenant)->create([ - 'item_count' => 2, + 'item_count' => 4, ]); - BackupItem::factory() + $policyItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -47,7 +55,7 @@ ]) ->create(); - BackupItem::factory() + $ignoredPolicyItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -59,7 +67,7 @@ ]) ->create(); - BackupItem::factory() + $scopeTagItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -77,6 +85,18 @@ ]) ->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(); $this->actingAs($user); @@ -84,13 +104,33 @@ ->fillForm([ 'backup_set_id' => $backupSet->id, ]) - ->assertSee('Policy Display') - ->assertDontSee('Ignored Policy') - ->assertSee('Scope Tag Alpha') - ->assertSee('Settings Catalog Policy') - ->assertSee('Scope Tag') - ->assertSee('restore: enabled') - ->assertSee('id: policy-1') - ->assertSee('id: tag-1') + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + ]) + ->assertFormFieldVisible('backup_item_ids') ->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'); }); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 2397796..5746b5b 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -77,12 +77,23 @@ $user = User::factory()->create(); $this->actingAs($user); - Livewire::test(CreateRestoreRun::class) + $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ '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 () { @@ -150,12 +161,19 @@ Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], 'group_mapping' => [ 'source-group-1' => 'target-group-1', ], - 'is_dry_run' => true, ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); diff --git a/tests/Feature/RestorePreviewDiffWizardTest.php b/tests/Feature/RestorePreviewDiffWizardTest.php new file mode 100644 index 0000000..ab62af1 --- /dev/null +++ b/tests/Feature/RestorePreviewDiffWizardTest.php @@ -0,0 +1,132 @@ + '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); +}); diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php new file mode 100644 index 0000000..c878d84 --- /dev/null +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -0,0 +1,222 @@ + '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'); +}); diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php new file mode 100644 index 0000000..4332917 --- /dev/null +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -0,0 +1,165 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'tester@example.com', + 'name' => 'Tester', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + ]) + ->call('create') + ->assertHasFormErrors(['acknowledged_impact', 'tenant_confirm']); + + expect(RestoreRun::count())->toBe(0); +}); + +test('restore run wizard queues execution when gates are satisfied', function () { + Bus::fake(); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'executor@example.com', + 'name' => 'Executor', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Tenant Two', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->status)->toBe(RestoreRunStatus::Queued->value); + expect($run->is_dry_run)->toBeFalse(); + expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com'); + expect($run->metadata['confirmed_at'] ?? null)->toBeString(); + + Bus::assertDispatched(ExecuteRestoreRunJob::class); +}); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php new file mode 100644 index 0000000..10c9697 --- /dev/null +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -0,0 +1,86 @@ + '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'); +});