From aa398770eb3ed135cd06b762e98b7658b930a074 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 3 Jan 2026 02:55:55 +0100 Subject: [PATCH] feat: sync endpoint security policies and improve settings display --- 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 ++- .../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 ++++++++ 39 files changed, 3024 insertions(+), 101 deletions(-) create mode 100644 app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php 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/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'); +});