From f32fdfb1e46f25192e2d67347d5ca31a8788596a Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 30 Dec 2025 21:49:38 +0100 Subject: [PATCH] feat: add restore risk checks --- app/Filament/Resources/RestoreRunResource.php | 143 +++- app/Services/Intune/RestoreRiskChecker.php | 608 ++++++++++++++++++ .../components/restore-run-checks.blade.php | 118 ++++ specs/011-restore-run-wizard/tasks.md | 4 +- tests/Feature/RestoreRiskChecksWizardTest.php | 130 ++++ 5 files changed, 995 insertions(+), 8 deletions(-) create mode 100644 app/Services/Intune/RestoreRiskChecker.php create mode 100644 resources/views/filament/forms/components/restore-run-checks.blade.php create mode 100644 tests/Feature/RestoreRiskChecksWizardTest.php diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index b27cdb9..c1a92b3 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -13,6 +13,7 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; @@ -176,6 +177,9 @@ public static function getWizardSteps(): array $set('backup_item_ids', null); $set('group_mapping', []); $set('is_dry_run', true); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); }) ->required(), ]), @@ -192,6 +196,9 @@ public static function getWizardSteps(): array ->reactive() ->afterStateUpdated(function (Set $set, $state): void { $set('group_mapping', []); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); if ($state === 'all') { $set('backup_item_ids', null); @@ -211,7 +218,12 @@ public static function getWizardSteps(): array ->optionsLimit(300) ->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id'))) ->reactive() - ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) + ->afterStateUpdated(function (Set $set): void { + $set('group_mapping', []); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); + }) ->visible(fn (Get $get): bool => $get('scope_mode') === 'selected') ->required(fn (Get $get): bool => $get('scope_mode') === 'selected') ->hintActions([ @@ -275,6 +287,12 @@ public static function getWizardSteps(): array ->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); + }) ->helperText('Choose a target group or select Skip.'); }, $unresolved); }) @@ -302,11 +320,98 @@ public static function getWizardSteps(): array }), ]), Step::make('Safety & Conflict Checks') - ->description('Defensive checks (Phase 4)') + ->description('Is this dangerous?') ->schema([ - Forms\Components\Placeholder::make('safety_checks_placeholder') - ->label('Status') - ->content('Safety & conflict checks will be added in Phase 4.'), + 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 = $get('group_mapping') ?? []; + $groupMapping = is_array($groupMapping) ? $groupMapping : []; + $groupMapping = collect($groupMapping) + ->map(fn ($value) => is_string($value) ? $value : null) + ->all(); + + $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); + + 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('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 (Phase 5)') @@ -858,7 +963,7 @@ public static function createRestoreRun(array $data): RestoreRun ? ($data['backup_item_ids'] ?? null) : null; - return $service->execute( + $restoreRun = $service->execute( tenant: $tenant, backupSet: $backupSet, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, @@ -867,6 +972,32 @@ public static function createRestoreRun(array $data): RestoreRun actorName: auth()->user()?->name, groupMapping: $data['group_mapping'] ?? [], ); + + $checkSummary = $data['check_summary'] ?? null; + $checkResults = $data['check_results'] ?? null; + $checksRanAt = $data['checks_ran_at'] ?? null; + + if (is_array($checkSummary) || is_array($checkResults) || (is_string($checksRanAt) && $checksRanAt !== '')) { + $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; + } + + $restoreRun->update([ + 'metadata' => $metadata, + ]); + } + + return $restoreRun->refresh(); } /** 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/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..fe1ff6d --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -0,0 +1,118 @@ +@php + $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/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 53f7a83..1e7ed97 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -22,8 +22,8 @@ ## Phase 3 — Restore Scope UX - [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`. diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php new file mode 100644 index 0000000..12c98a6 --- /dev/null +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -0,0 +1,130 @@ + '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() + ->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); +});