diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 041e51a..06622fc 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -52,6 +52,8 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt ->where('policy_type', $item->policy_type) ->first(); + $restoreMode = $this->resolveRestoreMode($item->policy_type); + return [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, @@ -59,6 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt 'platform' => $item->platform, 'action' => $existing ? 'update' : 'create', 'conflict' => false, + 'restore_mode' => $restoreMode, 'validation_warning' => BackupItem::odataTypeWarning( is_array($item->payload) ? $item->payload : [], $item->policy_type, @@ -151,6 +154,18 @@ public function execute( 'backup_item_id' => $item->id, ]; + $restoreMode = $this->resolveRestoreMode($item->policy_type); + + if ($restoreMode === 'preview-only') { + $results[] = $context + [ + 'status' => $dryRun ? 'dry_run' : 'skipped', + 'reason' => 'preview_only', + 'restore_mode' => $restoreMode, + ]; + + continue; + } + $odataValidation = BackupItem::validateODataType( is_array($item->payload) ? $item->payload : [], $item->policy_type, @@ -173,7 +188,10 @@ public function execute( } if ($dryRun) { - $results[] = $context + ['status' => 'dry_run']; + $results[] = $context + [ + 'status' => 'dry_run', + 'restore_mode' => $restoreMode, + ]; continue; } @@ -342,7 +360,10 @@ public function execute( } } - $result = $context + ['status' => $itemStatus]; + $result = $context + [ + 'status' => $itemStatus, + 'restore_mode' => $restoreMode, + ]; if ($settingsApply !== null) { $result['settings_apply'] = $settingsApply; @@ -459,6 +480,37 @@ private function splitItems(Collection $items): array return [$foundationItems, $policyItems]; } + /** + * @return array + */ + private function resolveTypeMeta(string $policyType): array + { + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + foreach ($types as $typeConfig) { + if (($typeConfig['type'] ?? null) === $policyType) { + return $typeConfig; + } + } + + return []; + } + + private function resolveRestoreMode(string $policyType): string + { + $meta = $this->resolveTypeMeta($policyType); + $restore = $meta['restore'] ?? 'enabled'; + + if (! is_string($restore) || $restore === '') { + return 'enabled'; + } + + return $restore; + } + /** * @param array> $entries * @return array> diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 8e6c5ee..00c852e 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -56,12 +56,22 @@
Policies
@foreach ($policyItems as $item) + @php + $restoreMode = $item['restore_mode'] ?? null; + @endphp
{{ $item['policy_identifier'] ?? 'Policy' }} - - {{ $item['action'] ?? 'action' }} - +
+ @if ($restoreMode === 'preview-only') + + preview-only + + @endif + + {{ $item['action'] ?? 'action' }} + +
{{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }} diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index c08365b..7a41afb 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -78,23 +78,36 @@
@php $status = $item['status'] ?? 'unknown'; + $restoreMode = $item['restore_mode'] ?? null; $statusColor = match ($status) { 'applied' => 'text-green-700 bg-green-100 border-green-200', 'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200', + 'skipped' => 'text-amber-900 bg-amber-50 border-amber-200', 'partial' => 'text-amber-900 bg-amber-50 border-amber-200', 'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200', 'failed' => 'text-red-700 bg-red-100 border-red-200', default => 'text-gray-700 bg-gray-100 border-gray-200', }; @endphp - - {{ $status }} - +
+ @if ($restoreMode === 'preview-only') + + preview-only + + @endif + + {{ $status }} + +
@php $itemReason = $item['reason'] ?? null; $itemGraphMessage = $item['graph_error_message'] ?? null; + + if ($itemReason === 'preview_only') { + $itemReason = 'Preview-only policy type; execution skipped.'; + } @endphp @if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason)) diff --git a/specs/006-sot-foundations-assignments/tasks.md b/specs/006-sot-foundations-assignments/tasks.md index cd68a2a..3540826 100644 --- a/specs/006-sot-foundations-assignments/tasks.md +++ b/specs/006-sot-foundations-assignments/tasks.md @@ -66,8 +66,8 @@ ## Phase 5: Conditional Access Preview-Only Enforcement **Purpose**: Keep CA restore preview-only even in execute mode. -- [ ] T016 Update `app/Services/Intune/RestoreService.php` to prevent CA execution (status skipped, reason preview_only) while keeping preview output. -- [ ] T017 Update restore UI to surface CA preview-only status in `resources/views/filament/infolists/entries/restore-preview.blade.php` and `resources/views/filament/infolists/entries/restore-results.blade.php`. +- [x] T016 Update `app/Services/Intune/RestoreService.php` to prevent CA execution (status skipped, reason preview_only) while keeping preview output. +- [x] T017 Update restore UI to surface CA preview-only status in `resources/views/filament/infolists/entries/restore-preview.blade.php` and `resources/views/filament/infolists/entries/restore-results.blade.php`. **Checkpoint**: CA items never execute; preview clearly signals preview-only. @@ -81,7 +81,7 @@ ## Phase 6: Tests and Verification - [x] T019 [P] Add unit tests for FoundationSnapshotService in `tests/Unit/FoundationSnapshotServiceTest.php`. - [x] T020 Add feature tests for foundations backup/restore preview and execute in `tests/Feature/FoundationBackupTest.php`, `tests/Feature/Filament/RestorePreviewTest.php`, `tests/Feature/Filament/RestoreExecutionTest.php`, `tests/Feature/RestoreScopeTagMappingTest.php`. - [x] T021 Add feature tests for assignment mapping and skip reasons in `tests/Feature/RestoreAssignmentApplicationTest.php`. -- [ ] T022 Add feature test for CA preview-only execution behavior in `tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php`. +- [x] T022 Add feature test for CA preview-only execution behavior in `tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php`. - [x] T023 Run tests: `./vendor/bin/sail artisan test tests/Unit/FoundationSnapshotServiceTest.php tests/Unit/FoundationMappingServiceTest.php tests/Unit/TenantPermissionServiceTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/Filament/RestorePreviewTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreAssignmentApplicationTest.php tests/Feature/RestoreScopeTagMappingTest.php tests/Feature/RestoreRunRerunTest.php` - [x] T024 Run Pint: `./vendor/bin/pint --dirty` diff --git a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php new file mode 100644 index 0000000..e86f144 --- /dev/null +++ b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php @@ -0,0 +1,111 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ca', + 'name' => 'Tenant CA', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'ca-policy-1', + 'policy_type' => 'conditionalAccessPolicy', + 'display_name' => 'CA Policy', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'CA 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' => [ + '@odata.type' => '#microsoft.graph.conditionalAccessPolicy', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'conditionalAccessPolicy'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +});