From 412dd7ad66793c9f7a9f97f318b39d61ae1738e2 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 3 Jan 2026 02:06:35 +0000 Subject: [PATCH] feat/017-policy-types-mam-endpoint-security-baselines (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data. Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing). Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/23 --- app/Filament/Resources/PolicyResource.php | 36 ++- .../PolicyResource/Pages/ListPolicies.php | 28 +- .../PolicyResource/Pages/ViewPolicy.php | 23 +- .../VersionsRelationManager.php | 2 + .../Resources/PolicyVersionResource.php | 6 +- app/Livewire/BackupSetPolicyPickerTable.php | 24 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Graph/GraphContractRegistry.php | 10 + app/Services/Graph/MicrosoftGraphClient.php | 228 +++++++++++++- .../Intune/DefaultPolicyNormalizer.php | 166 +++++++++- ...anagedDeviceAppConfigurationNormalizer.php | 143 +++++++++ .../Intune/PolicyCaptureOrchestrator.php | 16 +- app/Services/Intune/PolicySnapshotService.php | 132 +++++++- app/Services/Intune/PolicySyncService.php | 180 +++++++++-- app/Services/Intune/RestoreRiskChecker.php | 86 +++++ app/Services/Intune/RestoreService.php | 12 + .../SettingsCatalogDefinitionResolver.php | 39 +++ .../SettingsCatalogPolicyNormalizer.php | 2 +- app/Services/Intune/VersionService.php | 12 +- config/graph_contracts.php | 109 ++++++- config/tenantpilot.php | 42 ++- .../entries/policy-general.blade.php | 78 ++++- .../policy-settings-standard.blade.php | 46 ++- .../checklists/requirements.md | 7 + .../plan.md | 41 +++ .../spec.md | 47 +++ .../tasks.md | 56 ++++ .../BackupSetPolicyPickerTableTest.php | 99 ++++++ ...ingsCatalogPolicyNormalizedDisplayTest.php | 12 +- tests/Feature/PolicyGeneralViewTest.php | 41 +++ .../PolicySettingsStandardViewTest.php | 30 ++ ...rollmentConfigurationTypeCollisionTest.php | 74 +++++ tests/Feature/PolicySyncServiceReportTest.php | 61 ++++ tests/Feature/PolicySyncServiceTest.php | 219 +++++++++++++ tests/Feature/PolicyTypes017Test.php | 267 ++++++++++++++++ tests/Feature/RestoreRiskChecksWizardTest.php | 73 +++++ .../VersionCaptureMetadataOnlyTest.php | 66 ++++ .../GraphClientEndpointResolutionTest.php | 63 ++++ ...edDeviceAppConfigurationNormalizerTest.php | 45 +++ ...osoftGraphClientListPoliciesSelectTest.php | 160 ++++++++++ tests/Unit/PolicyCaptureOrchestratorTest.php | 64 ++++ tests/Unit/PolicySnapshotServiceTest.php | 293 ++++++++++++++++++ .../SettingsCatalogPolicyNormalizerTest.php | 136 ++++++++ 43 files changed, 3175 insertions(+), 101 deletions(-) create mode 100644 app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/plan.md create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/spec.md create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/tasks.md create mode 100644 tests/Feature/PolicyGeneralViewTest.php create mode 100644 tests/Feature/PolicySettingsStandardViewTest.php create mode 100644 tests/Feature/PolicySyncServiceReportTest.php create mode 100644 tests/Feature/PolicyTypes017Test.php create mode 100644 tests/Feature/VersionCaptureMetadataOnlyTest.php create mode 100644 tests/Unit/GraphClientEndpointResolutionTest.php create mode 100644 tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php create mode 100644 tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php create mode 100644 tests/Unit/PolicyCaptureOrchestratorTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bbfac49..371b8cc 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema TextEntry::make('external_id')->label('External ID'), TextEntry::make('last_synced_at')->dateTime()->label('Last synced'), TextEntry::make('created_at')->since(), + TextEntry::make('latest_snapshot_mode') + ->label('Snapshot') + ->badge() + ->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success') + ->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full') + ->helperText(function (Policy $record): ?string { + $meta = static::latestVersionMetadata($record); + + if (($meta['source'] ?? null) !== 'metadata_only') { + return null; + } + + $status = $meta['original_status'] ?? null; + + return sprintf( + 'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.', + $status ?? 'an error' + ); + }) + ->visible(fn (Policy $record) => $record->versions()->exists()), ]) ->columns(2) ->columnSpanFull(), @@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array return []; } + private static function latestVersionMetadata(Policy $record): array + { + $metadata = $record->relationLoaded('versions') + ? $record->versions->first()?->metadata + : $record->versions()->orderByDesc('captured_at')->value('metadata'); + + if (is_string($metadata)) { + $decoded = json_decode($metadata, true); + $metadata = $decoded ?? []; + } + + return is_array($metadata) ? $metadata : []; + } + /** * @return array */ @@ -764,7 +798,7 @@ private static function settingsTabState(Policy $record): array $rows = $normalized['settings_table']['rows'] ?? []; $hasSettingsTable = is_array($rows) && $rows !== []; - if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { + if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) { $split = static::splitGeneralBlock($normalized); return $split['normalized']; diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index e3743d2..222510c 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -28,11 +28,35 @@ protected function getHeaderActions(): array /** @var PolicySyncService $service */ $service = app(PolicySyncService::class); - $synced = $service->syncPolicies($tenant); + $result = $service->syncPoliciesWithReport($tenant); + $syncedCount = count($result['synced'] ?? []); + $failureCount = count($result['failures'] ?? []); + + $body = $syncedCount.' policies synced'; + + if ($failureCount > 0) { + $first = $result['failures'][0] ?? []; + $firstType = $first['policy_type'] ?? 'unknown'; + $firstStatus = $first['status'] ?? null; + + $firstErrorMessage = null; + $firstErrors = $first['errors'] ?? null; + if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) { + $firstErrorMessage = $firstErrors[0]['message'] ?? null; + } + + $suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}"; + + if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { + $suffix .= ' - '.trim($firstErrorMessage); + } + + $body .= " ({$failureCount} failed; {$suffix})"; + } Notification::make() ->title('Policy sync completed') - ->body(count($synced).' policies synced') + ->body($body) ->success() ->sendToDatabase(auth()->user()) ->send(); diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index 17c7b1b..1bfd70a 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -49,7 +49,7 @@ protected function getActions(): array return; } - app(VersionService::class)->captureFromGraph( + $version = app(VersionService::class)->captureFromGraph( tenant: $tenant, policy: $policy, createdBy: auth()->user()?->email ?? null, @@ -57,10 +57,23 @@ protected function getActions(): array includeScopeTags: $data['include_scope_tags'] ?? false, ); - Notification::make() - ->title('Snapshot captured successfully.') - ->success() - ->send(); + if (($version->metadata['source'] ?? null) === 'metadata_only') { + $status = $version->metadata['original_status'] ?? null; + + Notification::make() + ->title('Snapshot captured (metadata only)') + ->body(sprintf( + 'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.', + $status ?? 'an error' + )) + ->warning() + ->send(); + } else { + Notification::make() + ->title('Snapshot captured successfully.') + ->success() + ->send(); + } $this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()])); } catch (\Throwable $e) { diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 56f42cc..5a340ab 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -34,6 +34,8 @@ public function table(Table $table): Table ->label('Restore to Intune') ->icon('heroicon-o-arrow-path-rounded-square') ->color('danger') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') + ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") ->modalSubheading('Creates a restore run using this policy version snapshot.') diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 2b01621..b9dd732 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema return $normalized; }) - ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'), + ->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), Infolists\Components\ViewEntry::make('normalized_settings_standard') ->view('filament.infolists.entries.policy-settings-standard') @@ -91,7 +91,7 @@ public static function infolist(Schema $schema): Schema return $normalized; }) - ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'), + ->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), ]), Tab::make('Raw JSON') ->id('raw-json') @@ -194,6 +194,8 @@ public static function table(Table $table): Table ->label('Restore via Wizard') ->icon('heroicon-o-arrow-path-rounded-square') ->color('primary') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') + ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->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.') diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index dc17f1e..fdf340c 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -175,6 +175,9 @@ public function table(Table $table): Table $backupSet = BackupSet::query()->findOrFail($this->backupSetId); $tenant = $backupSet->tenant ?? Tenant::current(); + $beforeFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []); + $beforeFailureCount = count($beforeFailures); + $policyIds = $records->pluck('id')->all(); if ($policyIds === []) { @@ -201,10 +204,23 @@ public function table(Table $table): Table ? 'Backup items added' : 'Policies added to backup'; - Notification::make() - ->title($notificationTitle) - ->success() - ->send(); + $backupSet->refresh(); + + $afterFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []); + $afterFailureCount = count($afterFailures); + + if ($afterFailureCount > $beforeFailureCount) { + Notification::make() + ->title($notificationTitle.' with failures') + ->body('Some policies could not be captured from Microsoft Graph. Check the backup set failures list for details.') + ->warning() + ->send(); + } else { + Notification::make() + ->title($notificationTitle) + ->success() + ->send(); + } $this->resetTable(); }), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 10e2a8e..2bb90ea 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; +use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; @@ -45,6 +46,7 @@ public function register(): void DeviceConfigurationPolicyNormalizer::class, EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, + ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 9d07ff4..22f85c9 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array : array_map('trim', explode(',', (string) $original)); $filtered = array_values(array_intersect($select, $allowedSelect)); + $withoutAnnotations = array_values(array_filter( + $filtered, + static fn ($field) => is_string($field) && ! str_contains($field, '@') + )); + + if (count($withoutAnnotations) !== count($filtered)) { + $warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).'; + $filtered = $withoutAnnotations; + } + if (count($filtered) !== count($select)) { $warnings[] = 'Trimmed unsupported $select fields for capability safety.'; } diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 897e237..3f47bab 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface { private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; + private const MAX_LIST_PAGES = 50; + private string $baseUrl; private string $tokenUrlTemplate; @@ -51,12 +53,21 @@ public function __construct( public function listPolicies(string $policyType, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType); - $query = array_filter([ + $contract = $this->contracts->get($policyType); + $allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : []; + $defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null); + + $queryInput = array_filter([ '$top' => $options['top'] ?? null, '$filter' => $options['filter'] ?? null, + '$select' => $defaultSelect, 'platform' => $options['platform'] ?? null, ], fn ($value) => $value !== null && $value !== ''); + $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput); + $query = $sanitized['query']; + $warnings = $sanitized['warnings']; + $context = $this->resolveContext($options); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint, $query); @@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $response = $this->send('GET', $endpoint, $sendOptions, $context); - return $this->toGraphResponse( - action: 'list_policies', - response: $response, - transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), - meta: [ - 'tenant' => $context['tenant'] ?? null, - 'path' => $endpoint, - 'full_path' => $fullPath, + if ($response->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $response, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: $warnings, + ); + + if (! $this->shouldApplySelectFallback($graphResponse, $query)) { + return $graphResponse; + } + + $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH); + $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery); + $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId]; + + if (isset($options['access_token'])) { + $fallbackSendOptions['access_token'] = $options['access_token']; + } + + $this->logger->logRequest('list_policies_fallback', [ + 'endpoint' => $endpoint, + 'full_path' => $fallbackPath, 'method' => 'GET', - 'query' => $query ?: null, + 'policy_type' => $policyType, + 'tenant' => $context['tenant'], + 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, - ] + ]); + + $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context); + + if ($fallbackResponse->failed()) { + return $this->toGraphResponse( + action: 'list_policies', + response: $fallbackResponse, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fallbackPath, + 'method' => 'GET', + 'query' => $fallbackQuery ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: array_values(array_unique(array_merge( + $warnings, + ['Capability fallback applied: removed $select for compatibility.'] + ))), + ); + } + + $response = $fallbackResponse; + $query = $fallbackQuery; + $fullPath = $fallbackPath; + $warnings = array_values(array_unique(array_merge( + $warnings, + ['Capability fallback applied: removed $select for compatibility.'] + ))); + } + + $json = $response->json() ?? []; + $policies = $json['value'] ?? (is_array($json) ? $json : []); + $nextLink = $json['@odata.nextLink'] ?? null; + $pages = 1; + + while (is_string($nextLink) && $nextLink !== '') { + if ($pages >= self::MAX_LIST_PAGES) { + $graphResponse = new GraphResponse( + success: false, + data: [], + status: 500, + errors: [[ + 'message' => 'Graph pagination exceeded maximum page limit.', + 'max_pages' => self::MAX_LIST_PAGES, + ]], + warnings: $warnings, + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + 'pages_fetched' => $pages, + ], + ); + + $this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta); + + return $graphResponse; + } + + $pageOptions = ['client_request_id' => $clientRequestId]; + + if (isset($options['access_token'])) { + $pageOptions['access_token'] = $options['access_token']; + } + + $pageResponse = $this->send('GET', $nextLink, $pageOptions, $context); + + if ($pageResponse->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $pageResponse, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + 'pages_fetched' => $pages, + ], + warnings: array_values(array_unique(array_merge( + $warnings, + ['Pagination failed while listing policies.'] + ))), + ); + + return $graphResponse; + } + + $pageJson = $pageResponse->json() ?? []; + $pageValue = $pageJson['value'] ?? []; + + if (is_array($pageValue) && $pageValue !== []) { + $policies = array_merge($policies, $pageValue); + } + + $nextLink = $pageJson['@odata.nextLink'] ?? null; + $pages++; + } + + $meta = $this->responseMeta($response, [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ]); + + $meta['pages_fetched'] = $pages; + $meta['item_count'] = count($policies); + + if ($pages > 1) { + $warnings = array_values(array_unique(array_merge($warnings, [ + sprintf('Pagination applied: fetched %d pages.', $pages), + ]))); + } + + $graphResponse = new GraphResponse( + success: true, + data: $policies, + status: $response->status(), + warnings: $warnings, + meta: $meta, ); + + $this->logger->logResponse('list_policies', $graphResponse, $meta); + + return $graphResponse; } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse @@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options = return $graphResponse; } + private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool + { + if (! $graphResponse->failed()) { + return false; + } + + if (($graphResponse->status ?? null) !== 400) { + return false; + } + + if (! array_key_exists('$select', $query)) { + return false; + } + + $errorMessage = $graphResponse->meta['error_message'] ?? null; + + if (! is_string($errorMessage) || $errorMessage === '') { + return false; + } + + if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) { + return true; + } + + if (stripos($errorMessage, 'Could not find a property named') !== false) { + return true; + } + + return false; + } + public function getOrganization(array $options = []): GraphResponse { $context = $this->resolveContext($options); @@ -575,6 +776,11 @@ private function normalizeScopes(array|string|null $scope): array private function endpointFor(string $policyType): string { + $contractResource = $this->contracts->resourcePath($policyType); + if (is_string($contractResource) && $contractResource !== '') { + return $contractResource; + } + $supported = config('tenantpilot.supported_policy_types', []); foreach ($supported as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php index f890a10..067092c 100644 --- a/app/Services/Intune/DefaultPolicyNormalizer.php +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $resultWarnings = []; $status = 'success'; $settingsTable = null; + $usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); + $fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot); $validation = $this->validator->validate($snapshot); $resultWarnings = array_merge($resultWarnings, $validation['warnings']); @@ -60,23 +62,30 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor } if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); + if ($usesSettingsCatalogTable) { + $normalized = $this->buildSettingsCatalogSettingsTable( + $snapshot['settings'], + fallbackCategoryName: $fallbackCategoryName + ); $settingsTable = $normalized['table']; $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); } else { $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); } } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); + if ($usesSettingsCatalogTable) { + $normalized = $this->buildSettingsCatalogSettingsTable( + $snapshot['settingsDelta'], + 'Settings delta', + $fallbackCategoryName + ); $settingsTable = $normalized['table']; $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); } else { $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); } - } elseif ($policyType === 'settingsCatalogPolicy') { - $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; + } elseif ($usesSettingsCatalogTable) { + $resultWarnings[] = 'Settings not hydrated for this Configuration Policy.'; } $settings[] = $this->normalizeStandard($snapshot); @@ -231,13 +240,41 @@ private function normalizeSettingsCatalog(array $settings, string $title = 'Sett ]; } + private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string + { + $templateReference = $snapshot['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + return null; + } + + $displayName = $templateReference['templateDisplayName'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + return $displayName; + } + + $family = $templateReference['templateFamily'] ?? null; + + if (is_string($family) && $family !== '') { + return Str::headline($family); + } + + return null; + } + /** * @param array $settings * @return array{table: array, warnings: array} */ - private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array + private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array { - $flattened = $this->flattenSettingsCatalogSettingInstances($settings); + $flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName); return [ 'table' => [ @@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl * @param array $settings * @return array{rows: array>, warnings: array} */ - private function flattenSettingsCatalogSettingInstances(array $settings): array + private function flattenSettingsCatalogSettingInstances(array $settings, ?string $fallbackCategoryName = null): array { $rows = []; $warnings = []; @@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array &$warnedRowLimit, $definitions, $categories, - $defaultCategoryName + $defaultCategoryName, + $fallbackCategoryName, ): void { if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { if (! $warnedRowLimit) { @@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array $categoryName = $defaultCategoryName; } + if ( + $categoryName === '-' + && is_string($fallbackCategoryName) + && $fallbackCategoryName !== '' + && is_array($definition) + && ($definition['isFallback'] ?? false) + ) { + $categoryName = $fallbackCategoryName; + } + // Convert technical type to user-friendly data type $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); @@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance): $type = $instance['@odata.type'] ?? null; $type = is_string($type) ? $type : ''; + if (Str::contains($type, 'ChoiceSettingCollectionInstance', ignoreCase: true)) { + $collection = $instance['choiceSettingCollectionValue'] ?? null; + + if (! is_array($collection) || $collection === []) { + return []; + } + + $values = []; + + foreach ($collection as $item) { + if (! is_array($item)) { + continue; + } + + $value = $item['value'] ?? null; + + if (is_string($value) && $value !== '') { + $values[] = $value; + } + } + + return array_values(array_unique($values)); + } + if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { $simple = $instance['simpleSettingValue'] ?? null; if (is_array($simple)) { - return $simple['value'] ?? $simple; + $simpleValue = $simple['value'] ?? $simple; + + if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) { + return $simpleValue['value']; + } + + return $simpleValue; } return $simple; @@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance): $choice = $instance['choiceSettingValue'] ?? null; if (is_array($choice)) { - return $choice['value'] ?? $choice; + $choiceValue = $choice['value'] ?? $choice; + + if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) { + return $choiceValue['value']; + } + + return $choiceValue; } return $choice; @@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string if (is_string($value)) { // Remove {tenantid} placeholder $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); + $value = preg_replace('/\{[^}]+\}/', '', $value); $value = preg_replace('/_+/', '_', $value); // Extract choice label from choice values (last meaningful part) // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" - if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { + if ( + str_contains($value, 'device_vendor_msft') + || str_contains($value, 'user_vendor_msft') + || str_contains($value, 'vendor_msft') + || str_contains($value, '#microsoft.graph') + ) { $parts = explode('_', $value); $lastPart = end($parts); @@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; } + $commonLastPartMapping = [ + 'in' => 'Inbound', + 'out' => 'Outbound', + 'allow' => 'Allow', + 'block' => 'Block', + 'tcp' => 'TCP', + 'udp' => 'UDP', + 'icmpv4' => 'ICMPv4', + 'icmpv6' => 'ICMPv6', + 'any' => 'Any', + 'notconfigured' => 'Not configured', + 'lan' => 'LAN', + 'wireless' => 'Wireless', + 'remoteaccess' => 'Remote access', + 'domain' => 'Domain', + 'private' => 'Private', + 'public' => 'Public', + ]; + + if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) { + return $commonLastPartMapping[strtolower($lastPart)]; + } + // If last part is just a number, take second-to-last too if (is_numeric($lastPart) && count($parts) > 1) { $secondLast = $parts[count($parts) - 2]; @@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string } if (is_array($value)) { + if ($value === []) { + return '-'; + } + + if (array_is_list($value)) { + $parts = []; + + foreach ($value as $item) { + if ($item === null) { + continue; + } + + if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) { + $parts = []; + break; + } + + $parts[] = $this->formatSettingsCatalogValue($item); + } + + $parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-'))); + + if ($parts !== []) { + return implode(', ', $parts); + } + } + return json_encode($value); } diff --git a/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php b/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php new file mode 100644 index 0000000..27be266 --- /dev/null +++ b/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php @@ -0,0 +1,143 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + static function (array $block): bool { + $title = strtolower((string) ($block['title'] ?? '')); + + return $title !== 'settings' && $title !== 'settings delta'; + } + )); + + $rows = $this->buildSettingsRows($snapshot['settings'] ?? null); + + if ($rows !== []) { + $normalized['settings'][] = [ + 'type' => 'table', + 'title' => 'App configuration settings', + 'rows' => $rows, + ]; + } else { + $normalized['warnings'][] = 'No app configuration settings were returned by Graph. Intune only returns configured keys; items shown as "Not configured" in the portal are typically absent.'; + $normalized['warnings'] = array_values(array_unique(array_filter($normalized['warnings'], static fn ($value) => is_string($value) && $value !== ''))); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @return array> + */ + private function buildSettingsRows(mixed $settings): array + { + if (! is_array($settings) || $settings === []) { + return []; + } + + $rows = []; + + foreach ($settings as $setting) { + if (! is_array($setting)) { + continue; + } + + $key = $setting['appConfigKey'] ?? null; + $rawValue = $setting['appConfigKeyValue'] ?? null; + $type = $setting['appConfigKeyType'] ?? null; + + if (! is_string($key) || $key === '') { + continue; + } + + $value = $this->normalizeValue($rawValue, $type); + + $rows[] = [ + 'path' => $key, + 'label' => $key, + 'value' => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'description' => is_string($type) && $type !== '' ? Str::headline($type) : null, + ]; + } + + return $rows; + } + + private function normalizeValue(mixed $value, mixed $type): mixed + { + $type = is_string($type) ? strtolower($type) : ''; + + if (is_bool($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + if ($type !== '' && str_contains($type, 'boolean')) { + if (in_array(strtolower($trimmed), ['true', 'false'], true)) { + return strtolower($trimmed) === 'true'; + } + + if (in_array(strtolower($trimmed), ['yes', 'no'], true)) { + return strtolower($trimmed) === 'yes'; + } + + if (in_array($trimmed, ['1', '0'], true)) { + return $trimmed === '1'; + } + } + + if ($type !== '' && (str_contains($type, 'integer') || str_contains($type, 'int'))) { + if (is_numeric($trimmed) && (string) (int) $trimmed === $trimmed) { + return (int) $trimmed; + } + } + + return $trimmed; + } + + return $value; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index c495b73..b7a5f78 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -47,13 +47,21 @@ public function capture( $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { - throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'); + return [ + 'failure' => $snapshot['failure'], + ]; } $payload = $snapshot['payload']; $assignments = null; $scopeTags = null; - $captureMetadata = []; + $captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; + + $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; + if ($snapshotWarnings !== []) { + $existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : []; + $captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); + } // 2. Fetch assignments if requested if ($includeAssignments) { @@ -179,9 +187,9 @@ public function capture( // 5. Create new PolicyVersion with all captured data $metadata = array_merge( - ['source' => 'orchestrated_capture'], + ['capture_source' => 'orchestrated_capture'], $metadata, - $captureMetadata + $captureMetadata, ); $version = $this->versionService->captureVersion( diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 3e6b82a..33572eb 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -8,6 +8,7 @@ use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; +use App\Services\Graph\GraphResponse; use Illuminate\Support\Arr; use Throwable; @@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); + // For certain policy types experiencing upstream Graph issues, fall back to metadata-only + if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) { + return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status); + } + return [ 'failure' => [ 'policy_id' => $policy->id, @@ -87,8 +93,9 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } - if ($policy->policy_type === 'settingsCatalogPolicy') { - [$payload, $metadata] = $this->hydrateSettingsCatalog( + if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) { + [$payload, $metadata] = $this->hydrateConfigurationPolicySettings( + policyType: $policy->policy_type, tenantIdentifier: $tenantIdentifier, tenant: $tenant, policyId: $policy->external_id, @@ -118,7 +125,12 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null } if ($response->failed()) { - $reason = $response->warnings[0] ?? 'Graph request failed'; + $reason = $this->formatGraphFailureReason($response); + + if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) { + return $this->createMetadataOnlySnapshot($policy, $reason, $response->status); + } + $failure = [ 'policy_id' => $policy->id, 'reason' => $reason, @@ -162,6 +174,47 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + private function formatGraphFailureReason(GraphResponse $response): string + { + $code = $response->meta['error_code'] + ?? ($response->errors[0]['code'] ?? null) + ?? ($response->data['error']['code'] ?? null); + + $message = $response->meta['error_message'] + ?? ($response->errors[0]['message'] ?? null) + ?? ($response->data['error']['message'] ?? null) + ?? ($response->warnings[0] ?? null); + + $reason = 'Graph request failed'; + + if (is_string($message) && $message !== '') { + $reason = $message; + } + + if (is_string($code) && $code !== '') { + $reason = sprintf('%s: %s', $code, $reason); + } + + $requestId = $response->meta['request_id'] ?? null; + $clientRequestId = $response->meta['client_request_id'] ?? null; + + $suffixParts = []; + + if (is_string($clientRequestId) && $clientRequestId !== '') { + $suffixParts[] = sprintf('client_request_id=%s', $clientRequestId); + } + + if (is_string($requestId) && $requestId !== '') { + $suffixParts[] = sprintf('request_id=%s', $requestId); + } + + if ($suffixParts !== []) { + $reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts)); + } + + return $reason; + } + /** * Hydrate Windows Update Ring payload via derived type cast to capture * windowsUpdateForBusinessConfiguration-specific properties. @@ -263,14 +316,14 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload): } /** - * Hydrate settings catalog policies with configuration settings subresource. + * Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines). * * @return array{0:array,1:array} */ - private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { - $strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy'); - $settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId); + $strategy = $this->contracts->memberHydrationStrategy($policyType); + $settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId); if ($strategy !== 'subresource_settings' || ! $settingsPath) { return [$payload, $metadata]; @@ -592,6 +645,69 @@ private function stripGraphBaseUrl(string $nextLink): string return ltrim(substr($nextLink, strlen($base)), '/'); } - return ltrim($nextLink, '/'); + return $nextLink; + } + + /** + * Determine if we should fall back to metadata-only for this policy type and error. + */ + private function shouldFallbackToMetadata(string $policyType, ?int $status): bool + { + // Only fallback on 5xx server errors + if ($status === null || $status < 500 || $status >= 600) { + return false; + } + + // Enable fallback for policy types experiencing upstream Graph issues + $fallbackTypes = [ + 'mamAppConfiguration', + 'managedDeviceAppConfiguration', + ]; + + return in_array($policyType, $fallbackTypes, true); + } + + /** + * Create a metadata-only snapshot from the Policy model when Graph is unavailable. + * + * @return array{payload:array,metadata:array,warnings:array} + */ + private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array + { + $odataType = match ($policy->policy_type) { + 'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration', + 'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration', + default => '#microsoft.graph.'.$policy->policy_type, + }; + + $payload = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => $odataType, + 'createdDateTime' => $policy->created_at?->toIso8601String(), + 'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(), + ]; + + if ($policy->platform) { + $payload['platform'] = $policy->platform; + } + + $metadata = [ + 'source' => 'metadata_only', + 'original_failure' => $failureReason, + 'original_status' => $status, + 'warnings' => [ + sprintf( + 'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.', + $status ?? 'error' + ), + ], + ]; + + return [ + 'payload' => $payload, + 'metadata' => $metadata, + 'warnings' => $metadata['warnings'], + ]; } } diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index cf08815..1f87f19 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -25,6 +25,19 @@ public function __construct( * @return array IDs of policies synced or created */ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array + { + $result = $this->syncPoliciesWithReport($tenant, $supportedTypes); + + return $result['synced']; + } + + /** + * Sync supported policies for a tenant from Microsoft Graph. + * + * @param array|null $supportedTypes + * @return array{synced: array, failures: array} + */ + public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); @@ -32,6 +45,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr $types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []); $synced = []; + $failures = []; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; foreach ($types as $typeConfig) { @@ -69,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr ]); if ($response->failed()) { + $failures[] = [ + 'policy_type' => $policyType, + 'status' => $response->status, + 'errors' => $response->errors, + 'meta' => $response->meta, + ]; + continue; } @@ -109,6 +130,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr policyType: $policyType, ); + $this->reclassifyConfigurationPoliciesIfNeeded( + tenantId: $tenant->id, + externalId: $externalId, + policyType: $policyType, + ); + $policy = Policy::updateOrCreate( [ 'tenant_id' => $tenant->id, @@ -128,11 +155,18 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr } } - return $synced; + return [ + 'synced' => $synced, + 'failures' => $failures, + ]; } private function resolveCanonicalPolicyType(string $policyType, array $policyData): string { + if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) { + return $this->resolveConfigurationPolicyType($policyData); + } + if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { return $policyType; } @@ -141,11 +175,75 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat return 'windowsEnrollmentStatusPage'; } - if ($this->isEnrollmentRestrictionItem($policyData)) { - return 'enrollmentRestriction'; + return 'enrollmentRestriction'; + } + + private function resolveConfigurationPolicyType(array $policyData): string + { + if ($this->isSecurityBaselineConfigurationPolicy($policyData)) { + return 'securityBaselinePolicy'; } - return $policyType; + if ($this->isEndpointSecurityConfigurationPolicy($policyData)) { + return 'endpointSecurityPolicy'; + } + + return 'settingsCatalogPolicy'; + } + + private function isEndpointSecurityConfigurationPolicy(array $policyData): bool + { + $technologies = $policyData['technologies'] ?? null; + + if (is_string($technologies)) { + if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) { + return true; + } + } + + if (is_array($technologies)) { + foreach ($technologies as $technology) { + if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) { + return true; + } + } + } + + $templateReference = $policyData['templateReference'] ?? null; + + if (! is_array($templateReference)) { + return false; + } + + foreach ($templateReference as $value) { + if (is_string($value) && stripos($value, 'endpoint') !== false) { + return true; + } + } + + return false; + } + + private function isSecurityBaselineConfigurationPolicy(array $policyData): bool + { + $templateReference = $policyData['templateReference'] ?? null; + + if (! is_array($templateReference)) { + return false; + } + + $templateFamily = $templateReference['templateFamily'] ?? null; + if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) { + return true; + } + + foreach ($templateReference as $value) { + if (is_string($value) && stripos($value, 'baseline') !== false) { + return true; + } + } + + return false; } private function isEnrollmentStatusPageItem(array $policyData): bool @@ -157,33 +255,6 @@ private function isEnrollmentStatusPageItem(array $policyData): bool || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); } - private function isEnrollmentRestrictionItem(array $policyData): bool - { - $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; - $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; - - $restrictionOdataTypes = [ - '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', - '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', - '#microsoft.graph.deviceEnrollmentLimitConfiguration', - ]; - - if (is_string($odataType)) { - foreach ($restrictionOdataTypes as $expected) { - if (strcasecmp($odataType, $expected) === 0) { - return true; - } - } - } - - return is_string($configurationType) - && in_array($configurationType, [ - 'deviceEnrollmentPlatformRestrictionConfiguration', - 'deviceEnrollmentPlatformRestrictionsConfiguration', - 'deviceEnrollmentLimitConfiguration', - ], true); - } - private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void { if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { @@ -231,6 +302,53 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId ->update(['policy_type' => $policyType]); } + private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + $configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy']; + + if (! in_array($policyType, $configurationTypes, true)) { + return; + } + + $existingCorrect = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->first(); + + if ($existingCorrect) { + Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $configurationTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + return; + } + + $existingWrong = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $configurationTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->first(); + + if (! $existingWrong) { + return; + } + + $existingWrong->forceFill([ + 'policy_type' => $policyType, + ])->save(); + + PolicyVersion::query() + ->where('policy_id', $existingWrong->id) + ->update(['policy_type' => $policyType]); + } + /** * Re-fetch a single policy from Graph and update local metadata. */ diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php index 96ff30c..35383a4 100644 --- a/app/Services/Intune/RestoreRiskChecker.php +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -38,6 +38,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem $results = []; $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); + $results[] = $this->checkMetadataOnlySnapshots($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems); @@ -228,6 +229,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array ]; } + /** + * Detect snapshots that were captured as metadata-only. + * + * These snapshots cannot be safely restored because they do not contain the + * complete settings payload. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkMetadataOnlySnapshots(Collection $policyItems): ?array + { + $affected = []; + $hasRestoreEnabled = false; + + foreach ($policyItems as $item) { + if (! $this->isMetadataOnlySnapshot($item)) { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $affected[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'policy_type' => $item->policy_type, + 'label' => $item->resolvedDisplayName(), + 'restore_mode' => $restoreMode, + ]; + } + + if ($affected === []) { + return [ + 'code' => 'metadata_only', + 'severity' => 'safe', + 'title' => 'Snapshot completeness', + 'message' => 'No metadata-only snapshots detected.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.' + : 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.'; + + return [ + 'code' => 'metadata_only', + 'severity' => $severity, + 'title' => 'Snapshot completeness', + 'message' => $message, + 'meta' => [ + 'count' => count($affected), + 'items' => $this->truncateList($affected, 10), + ], + ]; + } + + private function isMetadataOnlySnapshot(BackupItem $item): bool + { + $metadata = is_array($item->metadata) ? $item->metadata : []; + + $source = $metadata['source'] ?? null; + $snapshotSource = $metadata['snapshot_source'] ?? null; + + if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') { + return true; + } + + $warnings = $metadata['warnings'] ?? null; + if (is_array($warnings)) { + foreach ($warnings as $warning) { + if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) { + return true; + } + } + } + + return false; + } + /** * @param Collection $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|null diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 85f23fe..e75040a 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -151,6 +151,18 @@ public function executeFromPolicyVersion( 'version_captured_at' => $version->captured_at?->toIso8601String(), ]; + $versionMetadata = is_array($version->metadata) ? $version->metadata : []; + $snapshotSource = $versionMetadata['source'] ?? null; + + if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') { + $backupItemMetadata['snapshot_source'] = $snapshotSource; + } + + $snapshotWarnings = $versionMetadata['warnings'] ?? null; + if (is_array($snapshotWarnings) && $snapshotWarnings !== []) { + $backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== ''))); + } + if (is_array($scopeTagIds) && $scopeTagIds !== []) { $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; } diff --git a/app/Services/Intune/SettingsCatalogDefinitionResolver.php b/app/Services/Intune/SettingsCatalogDefinitionResolver.php index 208ffa2..8566be3 100644 --- a/app/Services/Intune/SettingsCatalogDefinitionResolver.php +++ b/app/Services/Intune/SettingsCatalogDefinitionResolver.php @@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string // Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name $cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId); + // Remove other template placeholders, e.g. "{FirewallRuleId}" + $cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned); + // Clean up consecutive underscores $cleaned = preg_replace('/_+/', '_', $cleaned); $cleaned = trim($cleaned, '_'); + $lowered = Str::lower($cleaned); + + if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) { + $suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_'); + + if ($suffix === '') { + return 'Firewall rule'; + } + + $known = [ + 'displayname' => 'Name', + 'name' => 'Name', + 'description' => 'Description', + 'direction' => 'Direction', + 'action' => 'Action', + 'actiontype' => 'Action type', + 'profiles' => 'Profiles', + 'profile' => 'Profile', + 'protocol' => 'Protocol', + 'localport' => 'Local port', + 'remoteport' => 'Remote port', + 'localaddress' => 'Local address', + 'remoteaddress' => 'Remote address', + 'interfacetype' => 'Interface type', + 'interfacetypes' => 'Interface types', + 'edgetraversal' => 'Edge traversal', + 'enabled' => 'Enabled', + ]; + + if (isset($known[$suffix])) { + return $known[$suffix]; + } + + return Str::headline($suffix); + } + // Convert to title case $prettified = Str::title(str_replace('_', ' ', $cleaned)); diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php index 1b74907..e42a7a2 100644 --- a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -10,7 +10,7 @@ public function __construct( public function supports(string $policyType): bool { - return $policyType === 'settingsCatalogPolicy'; + return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); } /** diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index f1f300e..01992ef 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -85,6 +85,8 @@ public function captureFromGraph( } $payload = $snapshot['payload']; + $snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; + $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; $assignments = null; $scopeTags = null; $assignmentMetadata = []; @@ -141,11 +143,17 @@ public function captureFromGraph( } $metadata = array_merge( - ['source' => 'version_capture'], + $snapshotMetadata, + ['capture_source' => 'version_capture'], $metadata, - $assignmentMetadata + $assignmentMetadata, ); + if ($snapshotWarnings !== []) { + $existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : []; + $metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); + } + return $this->captureVersion( policy: $policy, payload: $payload, diff --git a/config/graph_contracts.php b/config/graph_contracts.php index eb9bd59..ed0536f 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -78,7 +78,7 @@ ], 'settingsCatalogPolicy' => [ 'resource' => 'deviceManagement/configurationPolicies', - 'allowed_select' => ['id', 'name', 'displayName', 'description', '@odata.type', 'version', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime'], + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'templateReference', 'roleScopeTagIds', 'lastModifiedDateTime'], 'allowed_expand' => ['settings'], 'type_family' => [ '#microsoft.graph.deviceManagementConfigurationPolicy', @@ -132,6 +132,76 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'endpointSecurityPolicy' => [ + 'resource' => 'deviceManagement/configurationPolicies', + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'member_hydration_strategy' => 'subresource_settings', + 'subresources' => [ + 'settings' => [ + 'path' => 'deviceManagement/configurationPolicies/{id}/settings', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'securityBaselinePolicy' => [ + 'resource' => 'deviceManagement/configurationPolicies', + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'member_hydration_strategy' => 'subresource_settings', + 'subresources' => [ + 'settings' => [ + 'path' => 'deviceManagement/configurationPolicies/{id}/settings', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'windowsUpdateRing' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], @@ -263,6 +333,43 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'assignments', ], + 'mamAppConfiguration' => [ + 'resource' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.targetedManagedAppConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'managedDeviceAppConfiguration' => [ + 'resource' => 'deviceAppManagement/mobileAppConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.managedDeviceMobileAppConfiguration', + '#microsoft.graph.mobileAppConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/microsoft.graph.managedDeviceMobileAppConfiguration/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'conditionalAccessPolicy' => [ 'resource' => 'identity/conditionalAccess/policies', 'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 7ec3820..e3d1ec9 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -84,6 +84,27 @@ 'restore' => 'enabled', 'risk' => 'medium-high', ], + [ + 'type' => 'mamAppConfiguration', + 'label' => 'App Configuration (MAM)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], + [ + 'type' => 'managedDeviceAppConfiguration', + 'label' => 'App Configuration (Device)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/mobileAppConfigurations', + 'filter' => "microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'conditionalAccessPolicy', 'label' => 'Conditional Access', @@ -140,7 +161,6 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -165,6 +185,26 @@ 'restore' => 'enabled', 'risk' => 'high', ], + [ + 'type' => 'endpointSecurityPolicy', + 'label' => 'Endpoint Security Policies', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/configurationPolicies', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'securityBaselinePolicy', + 'label' => 'Security Baselines', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/configurationPolicies', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'mobileApp', 'label' => 'Applications (Metadata only)', diff --git a/resources/views/filament/infolists/entries/policy-general.blade.php b/resources/views/filament/infolists/entries/policy-general.blade.php index 69bd5f8..c22bc0a 100644 --- a/resources/views/filament/infolists/entries/policy-general.blade.php +++ b/resources/views/filament/infolists/entries/policy-general.blade.php @@ -1,4 +1,7 @@ @php + use Carbon\CarbonImmutable; + use Illuminate\Support\Str; + $general = $getState(); $entries = is_array($general) ? ($general['entries'] ?? []) : []; $cards = []; @@ -61,6 +64,27 @@ 'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200', 'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200', ]; + + $formatIsoDateTime = static function (string $value): ?string { + $trimmed = trim($value); + + if ($trimmed === '') { + return null; + } + + if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $trimmed)) { + return null; + } + + // Graph can return 7 fractional digits; PHP supports 6 (microseconds). + $normalized = preg_replace('/\.(\d{6})\d+Z$/', '.$1Z', $trimmed); + + try { + return CarbonImmutable::parse($normalized)->toDateTimeString(); + } catch (\Throwable) { + return null; + } + }; @endphp @if (empty($cards)) @@ -72,6 +96,9 @@ $keyLower = $entry['key_lower'] ?? ''; $value = $entry['value'] ?? null; $isPlatform = str_contains($keyLower, 'platform'); + $isTechnologies = str_contains($keyLower, 'technolog'); + $isTemplateReference = str_contains($keyLower, 'template'); + $isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null; $toneKey = match (true) { str_contains($keyLower, 'name') => 'name', str_contains($keyLower, 'platform') => 'platform', @@ -88,6 +115,15 @@ $isBooleanValue = is_bool($value); $isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true); $isNumericValue = is_numeric($value); + + $badgeItems = null; + + if ($isListValue) { + $badgeItems = $value; + } elseif (($isPlatform || $isTechnologies) && is_string($value)) { + $split = array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== '')); + $badgeItems = $split !== [] ? $split : [$value]; + } @endphp
@@ -100,16 +136,50 @@ {{ $entry['key'] ?? '-' }}
- @if ($isListValue) + @if ($isTemplateReference && is_array($value)) + @php + $templateDisplayName = $value['templateDisplayName'] ?? null; + $templateFamily = $value['templateFamily'] ?? null; + $templateDisplayVersion = $value['templateDisplayVersion'] ?? null; + $templateId = $value['templateId'] ?? null; + + $familyLabel = is_string($templateFamily) && $templateFamily !== '' ? Str::headline($templateFamily) : null; + @endphp + +
+
+ {{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }} +
+ +
+ @if ($familyLabel) + {{ $familyLabel }} + @endif + @if (is_string($templateDisplayVersion) && $templateDisplayVersion !== '') + {{ $templateDisplayVersion }} + @endif +
+ + @if (is_string($templateId) && $templateId !== '') +
+ {{ $templateId }} +
+ @endif +
+ @elseif ($isDateTime) +
+ {{ $formattedDateTime }} +
+ @elseif (is_array($badgeItems) && $badgeItems !== [])
- @foreach ($value as $item) + @foreach ($badgeItems as $item) {{ $item }} @endforeach
@elseif ($isJsonValue) -
{{ json_encode($value, JSON_PRETTY_PRINT) }}
+
{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
@elseif ($isBooleanValue || $isBooleanString) @php $boolValue = $isBooleanValue @@ -126,7 +196,7 @@
@else
- {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }} + {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
@endif diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index f644e9f..2707044 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -59,6 +59,24 @@ return true; }; + + $asEnabledDisabledBadgeValue = function (mixed $value): ?bool { + if (is_bool($value)) { + return $value; + } + + if (! is_string($value)) { + return null; + } + + $normalized = strtolower(trim($value)); + + return match ($normalized) { + 'enabled', 'true', 'yes', '1' => true, + 'disabled', 'false', 'no', '0' => false, + default => null, + }; + }; @endphp
@@ -98,9 +116,13 @@
- @if(is_bool($row['value'])) - - {{ $row['value'] ? 'Enabled' : 'Disabled' }} + @php + $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null); + @endphp + + @if(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} @elseif(is_numeric($row['value'])) {{ $row['value'] }} @@ -135,16 +157,20 @@
@foreach($block['rows'] ?? [] as $row)
-
+
{{ $row['label'] ?? $row['path'] ?? 'Setting' }} @if(!empty($row['description']))

{{ Str::limit($row['description'], 80) }}

@endif
- @if(is_bool($row['value'])) - - {{ $row['value'] ? 'Enabled' : 'Disabled' }} + @php + $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null); + @endphp + + @if(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} @elseif(is_numeric($row['value'])) @@ -192,6 +218,8 @@ $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) && (bool) config('tenantpilot.display.show_script_content', false); + + $badgeValue = $asEnabledDisabledBadgeValue($rawValue); @endphp @if($isScriptContent) @@ -276,6 +304,10 @@ @endforeach
+ @elseif(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} + @else {{ Str::limit($stringifyValue($rawValue), 200) }} diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md b/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md new file mode 100644 index 0000000..90a1936 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md @@ -0,0 +1,7 @@ +# Requirements Checklist (017) + +- [x] Type keys and Graph resources confirmed for App Config Policies. +- [x] Type keys and Graph resources confirmed for Endpoint Security Policies. +- [x] Type keys and Graph resources confirmed for Security Baselines. +- [x] Restore mode decisions documented (enabled vs preview-only) per type. +- [x] Tests planned for sync + backup + preview. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/plan.md b/specs/017-policy-types-mam-endpoint-security-baselines/plan.md new file mode 100644 index 0000000..0f4d942 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/plan.md @@ -0,0 +1,41 @@ +# Plan: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Approach +1. Inventory current supported types (config + graph contracts) and identify gaps. +2. Define new type keys and metadata in `config/tenantpilot.php`. +3. Add graph contracts in `config/graph_contracts.php` (resource, assigns, scope tags, create/update methods). +4. Extend snapshot/capture and restore services as needed (special casing only when required). +5. Add tests for: sync listing + backup capture + restore preview entry. + +## Decisions + +### Type keys + Graph resources +- `mamAppConfiguration` (MAM App Config) + - Graph collection: `deviceAppManagement/targetedManagedAppConfigurations` + - Primary `@odata.type`: `#microsoft.graph.targetedManagedAppConfiguration` +- `endpointSecurityPolicy` (Endpoint Security Policies) + - Graph collection: `deviceManagement/configurationPolicies` + - Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy` + - Classification: configuration policies where the snapshot indicates Endpoint Security via `technologies` and/or `templateReference`. +- `securityBaselinePolicy` (Security Baselines) + - Graph collection: `deviceManagement/configurationPolicies` + - Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy` + - Classification: configuration policies where the snapshot indicates a baseline via `templateReference` (template family/type). + +### Restore modes +- `mamAppConfiguration`: `enabled` (risk: medium-high) +- `endpointSecurityPolicy`: `preview-only` (risk: high) +- `securityBaselinePolicy`: `preview-only` (risk: high) + +### Test plan +- Sync: new types show up with correct labels and do not leak into `settingsCatalogPolicy` / `appProtectionPolicy`. +- Backup: items created and snapshots captured for each new type. +- Restore: at minimum, restore preview produces entries; execution remains blocked for preview-only types. + +## Notes +- Default restore mode for security-sensitive types should be conservative (preview-only) unless we already have safe restore semantics. +- Prefer using existing generic graph-contract-driven code paths. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/spec.md b/specs/017-policy-types-mam-endpoint-security-baselines/spec.md new file mode 100644 index 0000000..293037a --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/spec.md @@ -0,0 +1,47 @@ +# Feature Specification: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Feature Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — MAM App Config backup & restore (Priority: P1) +As an admin, I want Managed App Configuration policies (App Config) to be inventoried, backed up, and restorable, so I can safely manage MAM configurations (Outlook, Teams, Edge, OneDrive, etc.) at scale. + +This includes both: +- App configuration (app-targeted) via `deviceAppManagement/targetedManagedAppConfigurations` +- App configuration (managed device) via `deviceAppManagement/mobileAppConfigurations` + +**Acceptance Scenarios** +1. Given a tenant with App Config policies, when I sync policies, then I can see them in the policy inventory with correct type labels. +2. Given a policy, when I add it to a backup set, then it is captured and a backup item is created. +3. Given a backup item, when I start a restore preview, then I can see a safe preview of changes. + +### User Story 2 — Endpoint Security policies (not only intents) (Priority: P1) +As an admin, I want Endpoint Security policies (Firewall/Defender/ASR/BitLocker etc.) supported, so the Windows security core can be backed up and restored. + +**Acceptance Scenarios** +1. Given Endpoint Security policies exist, sync shows them as their own policy type. +2. Backup captures them successfully. + +### User Story 3 — Security baselines (Priority: P1) +As an admin, I want Security Baselines supported because they are commonly used and are expected in a complete solution. + +**Acceptance Scenarios** +1. Given baseline policies exist, sync shows them. +2. Backup captures them. + +## Requirements + +### Functional Requirements +- **FR-001**: Add support for Managed App Configuration policies. +- **FR-002**: Add support for Endpoint Security policies beyond intents. +- **FR-003**: Add support for Security Baselines. +- **FR-004**: Each new type must integrate with: inventory, backup, restore preview, and (where safe) restore execution. +- **FR-005**: Changes must be covered by automated tests. + +## Success Criteria +- **SC-001**: New policy types appear in inventory & picker. +- **SC-002**: Backup/restore preview works for new types. +- **SC-003**: No regressions in existing policy flows. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md b/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md new file mode 100644 index 0000000..a031c80 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md @@ -0,0 +1,56 @@ +# Tasks: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Inventory & Design +- [x] T002 Inventory existing policy types and identify missing graph resources. +- [x] T003 Decide type keys + restore modes for: app config, endpoint security policies, security baselines. + +## Phase 3: Tests (TDD) +- [x] T004 Add tests for policy sync listing new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`). +- [x] T005 Add tests for backup capture creating backup items for new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`). +- [x] T006 Add tests for restore preview for new types (at least preview-only for `endpointSecurityPolicy`, `securityBaselinePolicy`). + +## Phase 4: Implementation +- [x] T007 Add new types to `config/tenantpilot.php`. +- [x] T008 Add new graph contracts to `config/graph_contracts.php`. +- [x] T009 Implement any required snapshot/capture/restore handling. + +## Phase 4b: Follow-up (MAM Device App Config) +- [x] T012 Add managed device app configurations (`mobileAppConfigurations`) to supported types + graph contracts + sync test. + +## Phase 5: Verification +- [x] T010 Run targeted tests. +- [x] T011 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 5b: UI Polish +- [x] T013 Render Enabled/Disabled-like string values as badges in settings views for consistent UI. + +## Phase 4c: Bugfix +- [x] T014 Ensure configuration policy list sync selects `technologies`/`templateReference` so Endpoint Security + Baselines can be classified. + +## Phase 4d: UX Debuggability +- [x] T015 Show per-type sync failures in Policy sync UI so 0-synced cases are actionable. + +## Phase 4e: Bugfix (Graph OData) +- [x] T016 Fix configuration policy list sync `$select` to avoid unsupported `version` field (Graph 400). + +## Phase 4f: Bugfix (Enrollment OData) +- [x] T017 Fix ESP (`windowsEnrollmentStatusPage`) sync filter to avoid Graph 400 "Invalid filter PropertyName". + +## Phase 4g: Bugfix (Endpoint Security Classification) +- [x] T018 Fix endpoint security configuration policies being misclassified as settings catalog when `technologies=mdm`. + +## Phase 4h: Bugfix (Graph Pagination) +- [x] T019 Paginate Graph list responses so Endpoint Security policies on page 2+ are synced. + +## Phase 4i: Feature (Endpoint Security Settings Display) +- [x] T020 Hydrate `configurationPolicies/{id}/settings` for `endpointSecurityPolicy` + `securityBaselinePolicy` snapshots. +- [x] T021 Render Endpoint Security + Baselines via Settings Catalog normalizer/table (diff + UI). +- [x] T022 Prettify Endpoint Security template settings (use `templateReference.templateDisplayName` as fallback category + nicer Firewall rule labels/values). +- [x] T023 Improve Policy General tab cards (template reference summary, badges, readable timestamps). diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php index e207423..a1a9143 100644 --- a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -53,6 +53,105 @@ ]) ->callTableBulkAction('add_selected_to_backup_set', $policies) ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('success'); +}); + +test('policy picker table does not warn if failures already existed but did not increase', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'status' => 'partial', + 'metadata' => [ + 'failures' => [ + ['policy_id' => 1, 'reason' => 'Previous failure', 'status' => 500], + ], + ], + ]); + + $policies = Policy::factory()->count(1)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->andReturn($backupSet); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('success'); +}); + +test('policy picker table warns when new failures were added', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'status' => 'completed', + 'metadata' => ['failures' => []], + ]); + + $policies = Policy::factory()->count(1)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->andReturnUsing(function () use ($backupSet) { + $backupSet->update([ + 'status' => 'partial', + 'metadata' => [ + 'failures' => [ + ['policy_id' => 123, 'reason' => 'New failure', 'status' => 500], + ], + ], + ]); + + return $backupSet->refresh(); + }); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added with failures'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('warning'); }); test('policy picker table can filter by has versions', function () { diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index 2505927..a83f411 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -11,7 +11,7 @@ uses(RefreshDatabase::class); -test('settings catalog policies render a normalized settings table', function () { +test('configuration policy types render a normalized settings table', function (string $policyType) { $tenant = Tenant::create([ 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', @@ -24,7 +24,7 @@ $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-policy-1', - 'policy_type' => 'settingsCatalogPolicy', + 'policy_type' => $policyType, 'display_name' => 'Settings Catalog Policy', 'platform' => 'windows', ]); @@ -33,7 +33,7 @@ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_number' => 1, - 'policy_type' => $policy->policy_type, + 'policy_type' => $policyType, 'platform' => $policy->platform, 'created_by' => 'tester@example.com', 'captured_at' => CarbonImmutable::now(), @@ -116,4 +116,8 @@ preg_match('/]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection); expect($versionGeneralSection)->not->toBeEmpty(); expect($versionGeneralSection[0])->toContain('x-cloak'); -}); +})->with([ + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); diff --git a/tests/Feature/PolicyGeneralViewTest.php b/tests/Feature/PolicyGeneralViewTest.php new file mode 100644 index 0000000..db59d92 --- /dev/null +++ b/tests/Feature/PolicyGeneralViewTest.php @@ -0,0 +1,41 @@ + fn (): array => [ + 'entries' => [ + ['key' => 'Name', 'value' => 'WindowsFirewall Endpointsecurity'], + ['key' => 'Platforms', 'value' => 'windows10'], + ['key' => 'Technologies', 'value' => 'mdm,microsoftSense'], + ['key' => 'Template Reference', 'value' => [ + 'templateId' => '19c8aa67-f286-4861-9aa0-f23541d31680_1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ]], + ['key' => 'Last Modified', 'value' => '2026-01-03T00:52:32.2784312Z'], + ], + ], + ], + )->render(); + + expect($html)->toContain('Windows Firewall Rules'); + expect($html)->toContain('Endpoint Security Firewall'); + expect($html)->toContain('Version 1'); + expect($html)->toContain('19c8aa67-f286-4861-9aa0-f23541d31680_1'); + + expect($html)->toContain('mdm'); + expect($html)->toContain('microsoftSense'); + expect($html)->toContain('fi-badge'); + + expect($html)->toContain('2026-01-03 00:52:32'); + expect($html)->not->toContain('T00:52:32.2784312Z'); + + expect($html)->not->toContain('"templateId"'); +}); diff --git a/tests/Feature/PolicySettingsStandardViewTest.php b/tests/Feature/PolicySettingsStandardViewTest.php new file mode 100644 index 0000000..1350e1c --- /dev/null +++ b/tests/Feature/PolicySettingsStandardViewTest.php @@ -0,0 +1,30 @@ + fn (): array => [ + 'settings' => [ + [ + 'type' => 'table', + 'title' => 'App configuration settings', + 'rows' => [ + ['label' => 'StringEnabled', 'value' => 'Enabled'], + ['label' => 'StringDisabled', 'value' => 'Disabled'], + ], + ], + ], + 'policy_type' => 'managedDeviceAppConfiguration', + ], + ], + )->render(); + + expect($html)->toContain('Enabled') + ->and($html)->toContain('Disabled') + ->and($html)->toContain('fi-badge'); +}); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php index a311e43..d296e33 100644 --- a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -71,3 +71,77 @@ expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage'); }); + +test('policy sync classifies ESP items without relying on Graph isof filter', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-sync-esp-no-filter', + 'name' => 'Tenant Sync ESP No Filter', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $payload = [ + [ + 'id' => 'esp-1', + 'displayName' => 'Enrollment Status Page', + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + ], + [ + 'id' => 'restriction-1', + 'displayName' => 'Default Enrollment Restriction', + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + ], + [ + 'id' => 'other-1', + 'displayName' => 'Other Enrollment Config', + '@odata.type' => '#microsoft.graph.someOtherEnrollmentConfiguration', + 'deviceEnrollmentConfigurationType' => 'someOtherEnrollmentConfiguration', + ], + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($payload) { + if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + return new GraphResponse(true, $payload); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + [ + 'type' => 'windowsEnrollmentStatusPage', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'enrollmentRestriction', + 'platform' => 'all', + 'filter' => null, + ], + ]); + + $espIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->pluck('external_id') + ->all(); + + $restrictionIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'enrollmentRestriction') + ->orderBy('external_id') + ->pluck('external_id') + ->all(); + + expect($espIds)->toMatchArray(['esp-1']); + expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']); +}); diff --git a/tests/Feature/PolicySyncServiceReportTest.php b/tests/Feature/PolicySyncServiceReportTest.php new file mode 100644 index 0000000..0c53c60 --- /dev/null +++ b/tests/Feature/PolicySyncServiceReportTest.php @@ -0,0 +1,61 @@ +create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) { + return match ($policyType) { + 'endpointSecurityPolicy' => new GraphResponse( + success: false, + data: [], + status: 403, + errors: [['message' => 'Forbidden']], + meta: ['path' => '/deviceManagement/configurationPolicies'], + ), + default => new GraphResponse( + success: true, + data: [ + ['id' => 'scp-1', 'displayName' => 'Settings Catalog', 'technologies' => ['mdm']], + ], + status: 200, + ), + }; + }); + + $service = app(PolicySyncService::class); + + $result = $service->syncPoliciesWithReport($tenant, [ + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ]); + + expect($result)->toHaveKeys(['synced', 'failures']); + expect($result['synced'])->toBeArray(); + expect($result['failures'])->toBeArray(); + + expect(count($result['synced']))->toBe(1); + expect(Policy::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + + expect(count($result['failures']))->toBe(1); + expect($result['failures'][0]['policy_type'])->toBe('endpointSecurityPolicy'); + expect($result['failures'][0]['status'])->toBe(403); +}); diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php index ef6d674..60beb81 100644 --- a/tests/Feature/PolicySyncServiceTest.php +++ b/tests/Feature/PolicySyncServiceTest.php @@ -1,6 +1,7 @@ toBe('deviceManagement/windowsQualityUpdateProfiles'); }); + +it('includes managed device app configurations in supported types', function () { + $supported = config('tenantpilot.supported_policy_types'); + $byType = collect($supported)->keyBy('type'); + + expect($byType)->toHaveKey('managedDeviceAppConfiguration'); + expect($byType['managedDeviceAppConfiguration']['endpoint'] ?? null) + ->toBe('deviceAppManagement/mobileAppConfigurations'); + expect($byType['managedDeviceAppConfiguration']['filter'] ?? null) + ->toBe("microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false"); +}); + +it('syncs managed device app configurations from Graph', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('managedDeviceAppConfiguration', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'madc-1', + 'displayName' => 'MAM Device Config', + '@odata.type' => '#microsoft.graph.managedDeviceMobileAppConfiguration', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'managedDeviceAppConfiguration', 'platform' => 'mobile'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'managedDeviceAppConfiguration')->count()) + ->toBe(1); +}); + +it('classifies configuration policies into settings catalog, endpoint security, and security baseline types', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $graphResponse = new GraphResponse( + success: true, + data: [ + [ + 'id' => 'scp-1', + 'name' => 'Settings Catalog Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => null, + ], + [ + 'id' => 'esp-1', + 'name' => 'Endpoint Security Beta', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => 'mdm', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityDiskEncryption', + 'templateDisplayName' => 'BitLocker', + ], + ], + [ + 'id' => 'sb-1', + 'name' => 'Security Baseline Gamma', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => [ + 'templateFamily' => 'securityBaseline', + ], + ], + ], + ); + + $calledTypes = []; + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->times(3) + ->andReturnUsing(function (string $policyType) use (&$calledTypes, $graphResponse) { + $calledTypes[] = $policyType; + + return $graphResponse; + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'securityBaselinePolicy', 'platform' => 'windows'], + ]); + + expect($calledTypes)->toMatchArray([ + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy', + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'settingsCatalogPolicy')->count()) + ->toBe(1); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'endpointSecurityPolicy')->count()) + ->toBe(1); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'securityBaselinePolicy')->count()) + ->toBe(1); +}); + +it('reclassifies configuration policies when canonical type changes', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Misclassified', + 'ignored_at' => null, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $graphResponse = new GraphResponse( + success: true, + data: [ + [ + 'id' => 'esp-1', + 'name' => 'Endpoint Security Beta', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => 'mdm', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityDiskEncryption', + 'templateDisplayName' => 'BitLocker', + ], + ], + ], + ); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->times(3) + ->andReturn($graphResponse); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'securityBaselinePolicy', 'platform' => 'windows'], + ]); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->whereNull('ignored_at') + ->count())->toBe(1); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->where('policy_type', 'endpointSecurityPolicy') + ->whereNull('ignored_at') + ->count())->toBe(1); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->where('policy_type', 'settingsCatalogPolicy') + ->whereNull('ignored_at') + ->count())->toBe(0); + + $version->refresh(); + + expect($version->policy_type)->toBe('endpointSecurityPolicy'); +}); diff --git a/tests/Feature/PolicyTypes017Test.php b/tests/Feature/PolicyTypes017Test.php new file mode 100644 index 0000000..3c3813d --- /dev/null +++ b/tests/Feature/PolicyTypes017Test.php @@ -0,0 +1,267 @@ +}> */ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'listPolicies', 'policyType' => $policyType, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + $payload = match ($policyType) { + 'mamAppConfiguration' => [ + 'id' => $policyId, + 'displayName' => 'MAM App Config', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + 'roleScopeTagIds' => ['0'], + ], + 'endpointSecurityPolicy' => [ + 'id' => $policyId, + 'name' => 'Endpoint Security Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + 'roleScopeTagIds' => ['0'], + ], + 'securityBaselinePolicy' => [ + 'id' => $policyId, + 'name' => 'Security Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + 'roleScopeTagIds' => ['0'], + ], + default => [ + 'id' => $policyId, + 'name' => 'Settings Catalog Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'roleScopeTagIds' => ['0'], + ], + }; + + return new GraphResponse(success: true, data: ['payload' => $payload]); + } + + public function getOrganization(array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getOrganization', 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getServicePrincipalPermissions', 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'request', 'path' => $path, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } +} + +it('creates backup items for the new 017 policy types', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $mam = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'mam-1', + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + ]); + + $esp = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + ]); + + $sb = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'sb-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + ]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('capture') + ->times(3) + ->andReturnUsing(function (Policy $policy) use ($tenant) { + $snapshot = match ($policy->policy_type) { + 'mamAppConfiguration' => [ + 'id' => $policy->external_id, + 'displayName' => 'MAM App Config', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + 'roleScopeTagIds' => ['0'], + ], + 'endpointSecurityPolicy' => [ + 'id' => $policy->external_id, + 'name' => 'Endpoint Security Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + 'roleScopeTagIds' => ['0'], + ], + 'securityBaselinePolicy' => [ + 'id' => $policy->external_id, + 'name' => 'Security Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + 'roleScopeTagIds' => ['0'], + ], + default => [ + 'id' => $policy->external_id, + 'name' => 'Settings Catalog Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'roleScopeTagIds' => ['0'], + ], + }; + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'snapshot' => $snapshot, + 'assignments' => null, + 'scope_tags' => null, + ]); + + return [ + 'version' => $version, + 'captured' => [ + 'payload' => $snapshot, + 'assignments' => null, + 'scope_tags' => null, + 'metadata' => [], + 'warnings' => [], + ], + ]; + }); + }); + + $service = app(BackupService::class); + $backupSet = $service->createBackupSet( + tenant: $tenant, + policyIds: [$mam->id, $esp->id, $sb->id], + actorEmail: $user->email, + actorName: $user->name, + name: '017 backup', + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + expect($backupSet->items)->toHaveCount(3); + + $types = $backupSet->items->pluck('policy_type')->all(); + sort($types); + + expect($types)->toBe([ + 'endpointSecurityPolicy', + 'mamAppConfiguration', + 'securityBaselinePolicy', + ]); + + expect(BackupItem::query()->where('backup_set_id', $backupSet->id)->count()) + ->toBe(3); +}); + +it('uses configured restore modes in preview for the new 017 policy types', function () { + $this->mock(GraphClientInterface::class); + + $tenant = Tenant::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 3, + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'mam-1', + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + 'payload' => [ + 'id' => 'mam-1', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + ], + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'sb-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'sb-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet); + + $byType = collect($preview)->keyBy('policy_type'); + + expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled'); + expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only'); + expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only'); +}); diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index c878d84..1fe987b 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -220,3 +220,76 @@ expect($skippedGroups)->toBeArray(); expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1'); }); + +test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', 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' => 'mamAppConfiguration', + 'display_name' => 'MAM App Config', + 'platform' => 'mobile', + ]); + + $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, 'displayName' => $policy->display_name], + 'assignments' => [], + 'metadata' => [ + 'source' => 'metadata_only', + 'warnings' => [ + 'Graph returned 500 for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.', + ], + ], + ]); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->andReturn([]); + }); + + $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() + ->callFormComponentAction('check_results', 'run_restore_checks'); + + $summary = $component->get('data.check_summary'); + $results = $component->get('data.check_results'); + + expect($summary['blocking'] ?? null)->toBe(1); + expect($summary['has_blockers'] ?? null)->toBeTrue(); + + $metadataOnly = collect($results)->firstWhere('code', 'metadata_only'); + expect($metadataOnly)->toBeArray(); + expect($metadataOnly['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/VersionCaptureMetadataOnlyTest.php b/tests/Feature/VersionCaptureMetadataOnlyTest.php new file mode 100644 index 0000000..6560ab7 --- /dev/null +++ b/tests/Feature/VersionCaptureMetadataOnlyTest.php @@ -0,0 +1,66 @@ +create(); + $policy = Policy::factory()->for($tenant)->create([ + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + 'external_id' => 'A_meta_only', + 'display_name' => 'MAM Config Meta', + ]); + + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'A_meta_only', + 'displayName' => 'MAM Config Meta', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + 'metadata' => [ + 'source' => 'metadata_only', + 'original_status' => 500, + 'original_failure' => 'InternalServerError: upstream', + ], + 'warnings' => [ + 'Snapshot captured from local metadata only (Graph API returned 500).', + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch')->never(); + }); + + $this->mock(ScopeTagResolver::class, function ($mock) { + $mock->shouldReceive('resolve')->never(); + }); + + $service = app(VersionService::class); + + $version = $service->captureFromGraph( + tenant: $tenant, + policy: $policy, + createdBy: 'tester@example.test', + includeAssignments: false, + includeScopeTags: false, + ); + + expect($version->metadata['source'])->toBe('metadata_only'); + expect($version->metadata['original_status'])->toBe(500); + expect($version->metadata['original_failure'])->toContain('InternalServerError'); + expect($version->metadata['capture_source'])->toBe('version_capture'); + expect($version->metadata['warnings'])->toBeArray(); + expect($version->metadata['warnings'][0])->toContain('metadata only'); +}); diff --git a/tests/Unit/GraphClientEndpointResolutionTest.php b/tests/Unit/GraphClientEndpointResolutionTest.php new file mode 100644 index 0000000..3c76a8a --- /dev/null +++ b/tests/Unit/GraphClientEndpointResolutionTest.php @@ -0,0 +1,63 @@ +set('graph.base_url', 'https://graph.microsoft.com'); + config()->set('graph.version', 'beta'); + config()->set('graph.tenant_id', 'tenant'); + config()->set('graph.client_id', 'client'); + config()->set('graph.client_secret', 'secret'); + config()->set('graph.scope', 'https://graph.microsoft.com/.default'); + + // Ensure we don't accidentally resolve via supported_policy_types + config()->set('tenantpilot.supported_policy_types', []); +}); + +it('uses graph contract resource path for applyPolicy', function () { + config()->set('graph_contracts.types.mamAppConfiguration', [ + 'resource' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'allowed_select' => ['id', 'displayName'], + 'allowed_expand' => [], + 'type_family' => ['#microsoft.graph.targetedManagedAppConfiguration'], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'A_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'mamAppConfiguration', + policyId: 'A_1', + payload: ['displayName' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1'); + }); +}); diff --git a/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php b/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php new file mode 100644 index 0000000..03266bc --- /dev/null +++ b/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php @@ -0,0 +1,45 @@ + 'policy-1', + 'displayName' => 'MAMDevice', + '@odata.type' => '#microsoft.graph.iosMobileAppConfiguration', + 'settings' => [ + [ + 'appConfigKey' => 'com.microsoft.outlook.EmailProfile.AccountType', + 'appConfigKeyType' => 'stringType', + 'appConfigKeyValue' => 'ModernAuth', + ], + [ + 'appConfigKey' => 'com.microsoft.outlook.Mail.FocusedInbox', + 'appConfigKeyType' => 'booleanType', + 'appConfigKeyValue' => 'true', + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'managedDeviceAppConfiguration', 'mobile'); + + $blocks = collect($normalized['settings'] ?? []); + + $appConfig = $blocks->firstWhere('title', 'App configuration settings'); + expect($appConfig)->not->toBeNull(); + expect($appConfig['type'] ?? null)->toBe('table'); + + $rows = collect($appConfig['rows'] ?? []); + $row = $rows->firstWhere('label', 'com.microsoft.outlook.EmailProfile.AccountType'); + expect($row)->not->toBeNull(); + expect($row['value'] ?? null)->toBe('ModernAuth'); + + $boolRow = $rows->firstWhere('label', 'com.microsoft.outlook.Mail.FocusedInbox'); + expect($boolRow)->not->toBeNull(); + expect($boolRow['value'] ?? null)->toBeTrue(); +}); diff --git a/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php b/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php new file mode 100644 index 0000000..8987140 --- /dev/null +++ b/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php @@ -0,0 +1,160 @@ + Http::response(['value' => []], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $client->listPolicies('endpointSecurityPolicy', [ + 'access_token' => 'test-token', + ]); + + $client->listPolicies('securityBaselinePolicy', [ + 'access_token' => 'test-token', + ]); + + $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + Http::assertSent(function (Request $request) { + $url = $request->url(); + + if (! str_contains($url, '/deviceManagement/configurationPolicies')) { + return false; + } + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($query)->toHaveKey('$select'); + + $select = (string) $query['$select']; + + expect($select)->toContain('technologies') + ->and($select)->toContain('templateReference') + ->and($select)->toContain('name') + ->and($select)->not->toContain('@odata.type'); + + expect($select)->not->toContain('displayName'); + expect($select)->not->toContain('version'); + + return true; + }); +}); + +it('retries list policies without $select on select/expand parsing errors', function () { + Http::fake([ + 'graph.microsoft.com/*' => Http::sequence() + ->push([ + 'error' => [ + 'code' => 'BadRequest', + 'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.", + ], + ], 400) + ->push([ + 'error' => [ + 'code' => 'BadRequest', + 'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.", + ], + ], 400) + ->push(['value' => [['id' => 'policy-1', 'name' => 'Policy One']]], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $response = $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + expect($response->successful())->toBeTrue(); + expect($response->data)->toHaveCount(1); + expect($response->warnings)->toContain('Capability fallback applied: removed $select for compatibility.'); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(3); + + [$firstRequest] = $recorded[0]; + [$secondRequest] = $recorded[1]; + [$thirdRequest] = $recorded[2]; + + parse_str((string) parse_url($firstRequest->url(), PHP_URL_QUERY), $firstQuery); + parse_str((string) parse_url($secondRequest->url(), PHP_URL_QUERY), $secondQuery); + parse_str((string) parse_url($thirdRequest->url(), PHP_URL_QUERY), $thirdQuery); + + expect($firstQuery)->toHaveKey('$select'); + expect($secondQuery)->toHaveKey('$select'); + expect($thirdQuery)->not->toHaveKey('$select'); +}); + +it('paginates list policies when nextLink is present', function () { + $nextLink = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$skiptoken=page2'; + + Http::fake([ + 'graph.microsoft.com/*' => Http::sequence() + ->push([ + 'value' => [ + ['id' => 'policy-1', 'name' => 'Policy One'], + ], + '@odata.nextLink' => $nextLink, + ], 200) + ->push([ + 'value' => [ + ['id' => 'policy-2', 'name' => 'Policy Two'], + ], + ], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $response = $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + expect($response->successful())->toBeTrue(); + expect($response->data)->toHaveCount(2); + expect(collect($response->data)->pluck('id')->all())->toMatchArray(['policy-1', 'policy-2']); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(2); + + [$firstRequest] = $recorded[0]; + [$secondRequest] = $recorded[1]; + + expect($firstRequest->url())->toContain('/deviceManagement/configurationPolicies'); + expect($secondRequest->url())->toBe($nextLink); +}); diff --git a/tests/Unit/PolicyCaptureOrchestratorTest.php b/tests/Unit/PolicyCaptureOrchestratorTest.php new file mode 100644 index 0000000..2bfc775 --- /dev/null +++ b/tests/Unit/PolicyCaptureOrchestratorTest.php @@ -0,0 +1,64 @@ +create([ + 'tenant_id' => 'tenant-1', + 'app_client_id' => 'client-1', + 'app_client_secret' => 'secret-1', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'mamAppConfiguration', + 'external_id' => 'A_f38e7f58-ac7c-455d-bb0e-f56bf1b3890e', + 'display_name' => 'MAM Example', + 'platform' => 'mobile', + ]); + + $snapshotService = Mockery::mock(PolicySnapshotService::class); + $snapshotService + ->shouldReceive('fetch') + ->once() + ->andReturn([ + 'failure' => [ + 'reason' => 'InternalServerError: upstream', + 'status' => 500, + ], + ]); + + $orchestrator = new PolicyCaptureOrchestrator( + versionService: Mockery::mock(VersionService::class), + snapshotService: $snapshotService, + assignmentFetcher: Mockery::mock(AssignmentFetcher::class), + groupResolver: Mockery::mock(GroupResolver::class), + assignmentFilterResolver: Mockery::mock(AssignmentFilterResolver::class), + scopeTagResolver: Mockery::mock(ScopeTagResolver::class), + ); + + $result = $orchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: true, + includeScopeTags: true, + createdBy: 'admin@example.test', + ); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(500); + expect($result['failure']['reason'])->toContain('InternalServerError'); +}); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 2bea6c1..9367f93 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -89,6 +89,68 @@ public function request(string $method, string $path, array $options = []): Grap } } +class ConfigurationPolicySettingsSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'name' => 'Endpoint Security Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path, $options]; + + if ($method === 'GET' && str_contains($path, 'deviceManagement/configurationPolicies/') && str_ends_with($path, '/settings')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'setting-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_firewall_policy_alpha', + 'simpleSettingValue' => [ + 'value' => true, + ], + ], + ], + ], + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + it('hydrates compliance policy scheduled actions into snapshots', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -125,6 +187,45 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); +it('hydrates configuration policy settings into snapshots', function (string $policyType) { + $client = new ConfigurationPolicySettingsSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-endpoint-security', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-123', + 'policy_type' => $policyType, + 'display_name' => 'Endpoint Security Alpha', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('settings'); + expect($result['payload']['settings'])->toHaveCount(1); + expect($result['metadata']['settings_hydration'] ?? null)->toBe('complete'); + + $paths = collect($client->requests) + ->filter(fn (array $entry): bool => ($entry[0] ?? null) === 'GET') + ->map(fn (array $entry): string => (string) ($entry[1] ?? '')) + ->values(); + + expect($paths->contains(fn (string $path): bool => str_contains($path, 'deviceManagement/configurationPolicies/esp-123/settings')))->toBeTrue(); +})->with([ + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); + it('filters mobile app snapshots to metadata-only keys', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -170,6 +271,123 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); +test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andThrow(new \App\Services\Graph\GraphException('InternalServerError: upstream', 500)); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-mam-fallback', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_fallback-policy', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM Config Alpha', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result)->toHaveKey('metadata'); + expect($result)->toHaveKey('warnings'); + expect($result['payload']['id'])->toBe('A_fallback-policy'); + expect($result['payload']['displayName'])->toBe('MAM Config Alpha'); + expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.targetedManagedAppConfiguration'); + expect($result['payload']['platform'])->toBe('iOS'); + expect($result['metadata']['source'])->toBe('metadata_only'); + expect($result['metadata']['original_status'])->toBe(500); + expect($result['warnings'])->toHaveCount(1); + expect($result['warnings'][0])->toContain('Snapshot captured from local metadata only'); + expect($result['warnings'][0])->toContain('Restore preview available, full restore not possible'); +}); + +test('does not fallback to metadata for non-5xx errors', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andThrow(new \App\Services\Graph\GraphException('NotFound', 404)); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-404', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_missing', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'Missing Policy', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(404); + expect($result['failure']['reason'])->toContain('NotFound'); +}); + +test('falls back to metadata-only when graph client returns failed response for mamAppConfiguration', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andReturn(new \App\Services\Graph\GraphResponse( + success: false, + data: [ + 'error' => [ + 'code' => 'InternalServerError', + 'message' => 'Upstream MAM failure', + ], + ], + status: 500, + errors: [['code' => 'InternalServerError', 'message' => 'Upstream MAM failure']], + meta: [ + 'client_request_id' => 'client-req-1', + 'request_id' => 'req-1', + ], + )); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-mam-fallback-response', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_resp_fallback', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM Config Response', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['metadata']['source'])->toBe('metadata_only'); + expect($result['metadata']['original_status'])->toBe(500); + expect($result['metadata']['original_failure'])->toContain('InternalServerError'); +}); + class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface { public array $requests = []; @@ -251,3 +469,78 @@ public function request(string $method, string $path, array $options = []): Grap expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete'); }); + +class FailedSnapshotGraphClient implements GraphClientInterface +{ + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse( + success: false, + data: [], + status: 500, + errors: [], + warnings: [], + meta: [ + 'error_code' => 'InternalServerError', + 'error_message' => 'An internal server error has occurred', + 'request_id' => 'req-123', + 'client_request_id' => 'client-456', + ], + ); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } +} + +it('returns actionable reasons when graph snapshot fails', function () { + app()->instance(GraphClientInterface::class, new FailedSnapshotGraphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-failure', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'mam-123', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Config', + 'platform' => 'mobile', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(500); + expect($result['failure']['reason'])->toContain('InternalServerError'); + expect($result['failure']['reason'])->toContain('An internal server error has occurred'); + expect($result['failure']['reason'])->toContain('client_request_id=client-456'); + expect($result['failure']['reason'])->toContain('request_id=req-123'); +}); diff --git a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php index fcc9a14..d4ddd36 100644 --- a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php +++ b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php @@ -31,3 +31,139 @@ expect($rows)->toHaveCount(1); expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); }); + +it('builds a settings table for endpoint security configuration policies', function (string $policyType) { + $normalizer = app(SettingsCatalogPolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, $policyType, 'windows'); + + $rows = $normalized['settings_table']['rows'] ?? []; + + expect($rows)->toHaveCount(1); + expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +})->with([ + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); + +it('prettifies endpoint security firewall rules settings for display', function () { + $normalizer = app(SettingsCatalogPolicyNormalizer::class); + + $groupDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}'; + $nameDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_displayname'; + $directionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_direction'; + $actionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_action'; + $interfaceTypesDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_interfacetypes'; + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 'rule-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance', + 'settingDefinitionId' => $groupDefinitionId, + 'groupSettingCollectionValue' => [ + [ + 'children' => [ + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => $nameDefinitionId, + 'simpleSettingValue' => [ + 'value' => 'Test0', + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => $directionDefinitionId, + 'choiceSettingValue' => [ + 'value' => "{$directionDefinitionId}_in", + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => $actionDefinitionId, + 'choiceSettingValue' => [ + 'value' => "{$actionDefinitionId}_allow", + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance', + 'settingDefinitionId' => $interfaceTypesDefinitionId, + 'choiceSettingCollectionValue' => [ + [ + 'value' => "{$interfaceTypesDefinitionId}_lan", + 'children' => [], + ], + [ + 'value' => "{$interfaceTypesDefinitionId}_remoteaccess", + 'children' => [], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'endpointSecurityPolicy', 'windows'); + $rows = collect($normalized['settings_table']['rows'] ?? []); + + $groupRow = $rows->firstWhere('definition_id', $groupDefinitionId); + expect($groupRow)->not->toBeNull(); + expect($groupRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($groupRow['definition'] ?? null)->toBe('Firewall rule'); + expect($groupRow['data_type'] ?? null)->toBe('Group'); + expect($groupRow['value'] ?? null)->toBe('(group)'); + + $nameRow = $rows->firstWhere('definition_id', $nameDefinitionId); + expect($nameRow)->not->toBeNull(); + expect($nameRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($nameRow['definition'] ?? null)->toBe('Name'); + expect($nameRow['value'] ?? null)->toBe('Test0'); + + $directionRow = $rows->firstWhere('definition_id', $directionDefinitionId); + expect($directionRow)->not->toBeNull(); + expect($directionRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($directionRow['definition'] ?? null)->toBe('Direction'); + expect($directionRow['data_type'] ?? null)->toBe('Choice'); + expect($directionRow['value'] ?? null)->toBe('Inbound'); + + $actionRow = $rows->firstWhere('definition_id', $actionDefinitionId); + expect($actionRow)->not->toBeNull(); + expect($actionRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($actionRow['definition'] ?? null)->toBe('Action'); + expect($actionRow['data_type'] ?? null)->toBe('Choice'); + expect($actionRow['value'] ?? null)->toBe('Allow'); + + $interfaceTypesRow = $rows->firstWhere('definition_id', $interfaceTypesDefinitionId); + expect($interfaceTypesRow)->not->toBeNull(); + expect($interfaceTypesRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($interfaceTypesRow['definition'] ?? null)->toBe('Interface types'); + expect($interfaceTypesRow['data_type'] ?? null)->toBe('Choice'); + expect($interfaceTypesRow['value'] ?? null)->toBe('LAN, Remote access'); +});