diff --git a/Agents.md b/Agents.md index 5b0f824..16f2830 100644 --- a/Agents.md +++ b/Agents.md @@ -365,6 +365,7 @@ ## Conventions - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. +- UI consistency: Prefer Filament components (``, infolist/table entries, etc.) over custom HTML/Tailwind for admin UI; only roll custom markup when Filament cannot express the UI. ## Verification Scripts - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 89cce02..bb3e19d 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -55,8 +55,9 @@ public static function infolist(Schema $schema): Schema ->columnSpanFull() ->tabs([ Tab::make('Normalized settings') + ->id('normalized-settings') ->schema([ - Infolists\Components\ViewEntry::make('normalized_settings') + Infolists\Components\ViewEntry::make('normalized_settings_catalog') ->view('filament.infolists.entries.normalized-settings') ->state(function (PolicyVersion $record) { $normalized = app(PolicyNormalizer::class)->normalize( @@ -69,15 +70,34 @@ public static function infolist(Schema $schema): Schema $normalized['record_id'] = (string) $record->getKey(); return $normalized; - }), + }) + ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'), + + Infolists\Components\ViewEntry::make('normalized_settings_standard') + ->view('filament.infolists.entries.policy-settings-standard') + ->state(function (PolicyVersion $record) { + $normalized = app(PolicyNormalizer::class)->normalize( + is_array($record->snapshot) ? $record->snapshot : [], + $record->policy_type ?? '', + $record->platform + ); + + $normalized['context'] = 'version'; + $normalized['record_id'] = (string) $record->getKey(); + + return $normalized; + }) + ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'), ]), Tab::make('Raw JSON') + ->id('raw-json') ->schema([ Infolists\Components\ViewEntry::make('snapshot_pretty') ->view('filament.infolists.entries.snapshot-json') ->state(fn (PolicyVersion $record) => $record->snapshot ?? []), ]), Tab::make('Diff') + ->id('diff') ->schema([ Infolists\Components\ViewEntry::make('normalized_diff') ->view('filament.infolists.entries.normalized-diff') @@ -93,8 +113,9 @@ public static function infolist(Schema $schema): Schema return $diff->compare($from, $to); }), - Infolists\Components\TextEntry::make('diff') - ->label('Diff JSON vs previous') + Infolists\Components\ViewEntry::make('diff_json') + ->label('Raw diff (advanced)') + ->view('filament.infolists.entries.snapshot-json') ->state(function (PolicyVersion $record) { $previous = $record->previous(); @@ -102,11 +123,38 @@ public static function infolist(Schema $schema): Schema return ['summary' => 'No previous version']; } - return app(VersionDiff::class) - ->compare($previous->snapshot ?? [], $record->snapshot ?? []); - }) - ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) - ->copyable(), + $diff = app(VersionDiff::class)->compare( + $previous->snapshot ?? [], + $record->snapshot ?? [] + ); + + $filter = static fn (array $items): array => array_filter( + $items, + static fn (mixed $value, string $key): bool => ! str_contains($key, '@odata.context'), + ARRAY_FILTER_USE_BOTH + ); + + $added = $filter($diff['added'] ?? []); + $removed = $filter($diff['removed'] ?? []); + $changed = $filter($diff['changed'] ?? []); + + return [ + 'summary' => [ + 'added' => count($added), + 'removed' => count($removed), + 'changed' => count($changed), + 'message' => sprintf( + '%d added, %d removed, %d changed', + count($added), + count($removed), + count($changed) + ), + ], + 'added' => $added, + 'removed' => $removed, + 'changed' => $changed, + ]; + }), ]), ]), ]); diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php index 6211e6c..4067708 100644 --- a/app/Livewire/PolicyVersionAssignmentsWidget.php +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -14,10 +14,114 @@ public function mount(PolicyVersion $version): void $this->version = $version; } - public function render() + public function render(): \Illuminate\Contracts\View\View { return view('livewire.policy-version-assignments-widget', [ 'version' => $this->version, + 'compliance' => $this->complianceNotifications(), ]); } + + /** + * @return array{total:int,templates:array,items:array} + */ + private function complianceNotifications(): array + { + if ($this->version->policy_type !== 'deviceCompliancePolicy') { + return [ + 'total' => 0, + 'templates' => [], + 'items' => [], + ]; + } + + $snapshot = $this->version->snapshot; + + if (! is_array($snapshot)) { + return [ + 'total' => 0, + 'templates' => [], + 'items' => [], + ]; + } + + $scheduled = $snapshot['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return [ + 'total' => 0, + 'templates' => [], + 'items' => [], + ]; + } + + $items = []; + $templateIds = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $ruleName = $rule['ruleName'] ?? null; + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + continue; + } + + foreach ($configs as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['actionType'] ?? null) !== 'notification') { + continue; + } + + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($templateKey === null) { + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + continue; + } + + $items[] = [ + 'rule_name' => is_string($ruleName) ? $ruleName : null, + 'template_id' => $templateId, + 'template_key' => $templateKey, + ]; + $templateIds[] = $templateId; + } + } + + return [ + 'total' => count($items), + 'templates' => array_values(array_unique($templateIds)), + 'items' => $items, + ]; + } + + private function resolveNotificationTemplateKey(array $config): ?string + { + if (array_key_exists('notificationTemplateId', $config)) { + return 'notificationTemplateId'; + } + + if (array_key_exists('notificationMessageTemplateId', $config)) { + return 'notificationMessageTemplateId'; + } + + return null; + } + + private function isEmptyGuid(string $value): bool + { + return strtolower($value) === '00000000-0000-0000-0000-000000000000'; + } } diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 37afda0..abf23f0 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -25,22 +25,41 @@ public function sanitizeQuery(string $policyType, array $query): array $allowedExpand = $contract['allowed_expand'] ?? []; $warnings = []; - if (! empty($query['$select']) && is_array($query['$select'])) { + if (! empty($query['$select'])) { $original = $query['$select']; - $query['$select'] = array_values(array_intersect($original, $allowedSelect)); + $select = is_array($original) + ? $original + : array_map('trim', explode(',', (string) $original)); + $filtered = array_values(array_intersect($select, $allowedSelect)); - if (count($query['$select']) !== count($original)) { + if (count($filtered) !== count($select)) { $warnings[] = 'Trimmed unsupported $select fields for capability safety.'; } + + if ($filtered === []) { + unset($query['$select']); + } else { + $query['$select'] = implode(',', $filtered); + } } - if (! empty($query['$expand']) && is_array($query['$expand'])) { + if (! empty($query['$expand'])) { $original = $query['$expand']; - $query['$expand'] = array_values(array_intersect($original, $allowedExpand)); + $expand = is_array($original) + ? $original + : [trim((string) $original)]; + $expand = array_values(array_filter($expand, static fn ($value) => $value !== '')); + $filtered = array_values(array_intersect($expand, $allowedExpand)); - if (count($query['$expand']) !== count($original)) { + if (count($filtered) !== count($expand)) { $warnings[] = 'Trimmed unsupported $expand fields for capability safety.'; } + + if ($filtered === []) { + unset($query['$expand']); + } else { + $query['$expand'] = implode(',', $filtered); + } } return [ diff --git a/app/Services/Intune/CompliancePolicyNormalizer.php b/app/Services/Intune/CompliancePolicyNormalizer.php index be66042..2bcd299 100644 --- a/app/Services/Intune/CompliancePolicyNormalizer.php +++ b/app/Services/Intune/CompliancePolicyNormalizer.php @@ -44,7 +44,12 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + $snapshot = $snapshot ?? []; + + $normalized = $this->normalize($snapshot, $policyType, $platform); + $flat = $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + + return array_merge($flat, $this->flattenComplianceNotificationsForDiff($snapshot)); } /** @@ -85,6 +90,85 @@ private function buildComplianceBlocks(array $snapshot): array return $blocks; } + /** + * @return array + */ + private function flattenComplianceNotificationsForDiff(array $snapshot): array + { + $scheduled = $snapshot['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return []; + } + + $templateIds = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + continue; + } + + foreach ($configs as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['actionType'] ?? null) !== 'notification') { + continue; + } + + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($templateKey === null) { + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + continue; + } + + $templateIds[] = $templateId; + } + } + + $templateIds = array_values(array_unique($templateIds)); + sort($templateIds); + + if ($templateIds === []) { + return []; + } + + return [ + 'Compliance notifications > Template IDs' => $templateIds, + ]; + } + + private function resolveNotificationTemplateKey(array $config): ?string + { + if (array_key_exists('notificationTemplateId', $config)) { + return 'notificationTemplateId'; + } + + if (array_key_exists('notificationMessageTemplateId', $config)) { + return 'notificationMessageTemplateId'; + } + + return null; + } + + private function isEmptyGuid(string $value): bool + { + return strtolower($value) === '00000000-0000-0000-0000-000000000000'; + } + /** * @return array{keys: array, labels?: array} */ @@ -282,6 +366,8 @@ private function ignoredKeys(): array 'settingCount', 'settingsCount', 'templateReference', + 'scheduledActionsForRule@odata.context', + 'scheduledActionsForRule', ]; } diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php index 6cf7145..f890a10 100644 --- a/app/Services/Intune/DefaultPolicyNormalizer.php +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -106,23 +106,49 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->flattenNormalizedForDiff($normalized); + } + + /** + * Flatten an already normalized payload into key/value pairs for diffing. + * + * @param array{settings: array>, settings_table?: array} $normalized + * @return array + */ + public function flattenNormalizedForDiff(array $normalized): array + { $map = []; if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { + $title = $normalized['settings_table']['title'] ?? 'Settings'; + $prefix = is_string($title) && $title !== '' ? $title.' > ' : ''; + foreach ($normalized['settings_table']['rows'] as $row) { if (! is_array($row)) { continue; } - $key = $row['path'] ?? $row['definition'] ?? 'entry'; + $key = $prefix.($row['path'] ?? $row['definition'] ?? 'entry'); $map[$key] = $row['value'] ?? null; } } - foreach ($normalized['settings'] as $block) { + foreach ($normalized['settings'] ?? [] as $block) { + if (! is_array($block)) { + continue; + } + + $title = $block['title'] ?? null; + $prefix = is_string($title) && $title !== '' ? $title.' > ' : ''; + if (($block['type'] ?? null) === 'table') { foreach ($block['rows'] ?? [] as $row) { - $key = $row['path'] ?? $row['label'] ?? 'entry'; + if (! is_array($row)) { + continue; + } + + $key = $prefix.($row['path'] ?? $row['label'] ?? 'entry'); $map[$key] = $row['value'] ?? null; } @@ -130,7 +156,11 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl } foreach ($block['entries'] ?? [] as $entry) { - $key = $entry['key'] ?? 'entry'; + if (! is_array($entry)) { + continue; + } + + $key = $prefix.($entry['key'] ?? 'entry'); $map[$key] = $entry['value'] ?? null; } } @@ -554,6 +584,8 @@ private function normalizeStandard(array $snapshot): array 'omaSettings', 'settings', 'settingsDelta', + 'scheduledActionsForRule', + 'scheduledActionsForRule@odata.context', ]; $filtered = Arr::except($snapshot, $metadataKeys); diff --git a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php index 5c88051..12574da 100644 --- a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php +++ b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php @@ -46,7 +46,10 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); } /** diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 87c9d6c..031c561 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -39,12 +39,18 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $this->graphLogger->logRequest('get_policy', $context); try { - $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [ + $options = [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $policy->platform, - ]); + ]; + + if ($policy->policy_type === 'deviceCompliancePolicy') { + $options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)'; + } + + $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options); } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); @@ -73,6 +79,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } + if ($policy->policy_type === 'deviceCompliancePolicy') { + [$payload, $metadata] = $this->hydrateComplianceActions( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + policyId: $policy->external_id, + payload: is_array($payload) ? $payload : [], + metadata: $metadata + ); + } + if ($response->failed()) { $reason = $response->warnings[0] ?? 'Graph request failed'; $failure = [ @@ -174,6 +190,68 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant return [$payload, $metadata]; } + /** + * Hydrate compliance policies with scheduled actions (notification templates). + * + * @return array{0:array,1:array} + */ + private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + { + $existingActions = $payload['scheduledActionsForRule'] ?? null; + + if (is_array($existingActions) && $existingActions !== []) { + $metadata['compliance_actions_hydration'] = 'embedded'; + + return [$payload, $metadata]; + } + + $path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId)); + $options = [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]; + + $actions = []; + $nextPath = $path; + $hydrationStatus = 'complete'; + + while ($nextPath) { + $response = $this->graphClient->request('GET', $nextPath, $options); + + if ($response->failed()) { + $hydrationStatus = 'failed'; + + break; + } + + $data = $response->data; + $pageItems = $data['value'] ?? (is_array($data) ? $data : []); + + foreach ($pageItems as $item) { + if (is_array($item)) { + $actions[] = $item; + } + } + + $nextLink = $data['@odata.nextLink'] ?? null; + + if (! $nextLink) { + break; + } + + $nextPath = $this->stripGraphBaseUrl((string) $nextLink); + } + + if (! empty($actions)) { + $payload['scheduledActionsForRule'] = $actions; + } + + $metadata['compliance_actions_hydration'] = $hydrationStatus; + + return [$payload, $metadata]; + } + /** * Extract all settingDefinitionId from settings array, including nested children. */ diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index c5a36a1..04d729c 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -242,6 +242,7 @@ public function execute( 'client_secret' => $tenant->app_client_secret, 'platform' => $item->platform, ]; + $updateMethod = $this->resolveUpdateMethod($item->policy_type); $settingsApply = null; $itemStatus = 'applied'; @@ -249,6 +250,7 @@ public function execute( $resultReason = null; $createdPolicyId = null; $createdPolicyMode = null; + $settingsApplyEligible = false; if ($item->policy_type === 'settingsCatalogPolicy') { $settings = $this->extractSettingsCatalogSettings($originalPayload); @@ -258,10 +260,55 @@ public function execute( $item->policy_type, $item->policy_identifier, $policyPayload, - $graphOptions + $graphOptions + ['method' => $updateMethod] ); - if ($response->successful() && $settings !== []) { + $settingsApplyEligible = $response->successful(); + + if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + $createOutcome = $this->createSettingsCatalogPolicy( + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + fallbackName: $item->resolvedDisplayName(), + ); + + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = $createOutcome['mode'] ?? null; + $mode = $createOutcome['mode'] ?? 'settings'; + + $itemStatus = $mode === 'settings' ? 'applied' : 'partial'; + $resultReason = $mode === 'metadata_only' + ? 'Policy missing; created metadata-only policy. Manual settings apply required.' + : 'Policy missing; created new policy with settings.'; + + if ($settings !== []) { + $settingsApply = $mode === 'metadata_only' + ? [ + 'total' => count($settings), + 'applied' => 0, + 'failed' => 0, + 'manual_required' => count($settings), + 'issues' => [], + ] + : [ + 'total' => count($settings), + 'applied' => count($settings), + 'failed' => 0, + 'manual_required' => 0, + 'issues' => [], + ]; + } + + $settingsApplyEligible = false; + } + } + + if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( policyId: $item->policy_identifier, settings: $settings, @@ -314,7 +361,7 @@ public function execute( ]; } } - } elseif ($settings !== []) { + } elseif ($settingsApplyEligible && $settings !== []) { $settingsApply = [ 'total' => count($settings), 'applied' => 0, @@ -328,7 +375,7 @@ public function execute( $item->policy_type, $item->policy_identifier, $payload, - $graphOptions + $graphOptions + ['method' => $updateMethod] ); if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { @@ -349,6 +396,26 @@ public function execute( $resultReason = 'Policy missing; created new Autopilot profile.'; } } + } elseif ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + $createOutcome = $this->createPolicyFromSnapshot( + policyType: $item->policy_type, + payload: $payload, + originalPayload: $originalPayload, + graphOptions: $graphOptions, + context: $context, + fallbackName: $item->resolvedDisplayName(), + ); + + if ($createOutcome['attempted']) { + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = 'created'; + $itemStatus = 'applied'; + $resultReason = 'Policy missing; created new policy.'; + } + } } } } catch (Throwable $throwable) { @@ -602,6 +669,52 @@ private function resolveRestoreMode(string $policyType): string return $restore; } + private function resolveUpdateMethod(string $policyType): string + { + $contract = $this->contracts->get($policyType); + $method = strtoupper((string) ($contract['update_method'] ?? 'PATCH')); + + return $method !== '' ? $method : 'PATCH'; + } + + private function resolveCreateMethod(string $policyType): ?string + { + $contract = $this->contracts->get($policyType); + $method = strtoupper((string) ($contract['create_method'] ?? 'POST')); + + return $method !== '' ? $method : null; + } + + private function shouldAttemptPolicyCreate(string $policyType, object $response): bool + { + if (! $this->isNotFoundResponse($response)) { + return false; + } + + $resource = $this->contracts->resourcePath($policyType); + $method = $this->resolveCreateMethod($policyType); + + return is_string($resource) && $resource !== '' && $method !== null; + } + + private function isNotFoundResponse(object $response): bool + { + if (($response->status ?? null) === 404) { + return true; + } + + $code = strtolower((string) ($response->meta['error_code'] ?? '')); + $message = strtolower((string) ($response->meta['error_message'] ?? '')); + + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { + return true; + } + + return $message !== '' && (str_contains($message, 'not found') + || str_contains($message, 'resource not found') + || str_contains($message, 'does not exist')); + } + /** * @param array> $entries * @return array> @@ -1359,6 +1472,70 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} + */ + private function createPolicyFromSnapshot( + string $policyType, + array $payload, + array $originalPayload, + array $graphOptions, + array $context, + string $fallbackName, + ): array { + $resource = $this->contracts->resourcePath($policyType); + $method = $this->resolveCreateMethod($policyType); + + if (! is_string($resource) || $resource === '' || $method === null) { + return [ + 'attempted' => false, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $createPayload = Arr::except($payload, ['assignments']); + $createPayload = $this->applyOdataTypeForCreate($policyType, $createPayload, $originalPayload); + $createPayload = $this->applyRestoredNameToPayload($createPayload, $originalPayload, $fallbackName); + + if ($createPayload === []) { + return [ + 'attempted' => true, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $this->graphLogger->logRequest('create_policy', $context + [ + 'endpoint' => $resource, + 'method' => $method, + 'policy_type' => $policyType, + ]); + + $response = $this->graphClient->request( + $method, + $resource, + ['json' => $createPayload] + Arr::except($graphOptions, ['platform']) + ); + + $this->graphLogger->logResponse('create_policy', $response, $context + [ + 'endpoint' => $resource, + 'method' => $method, + 'policy_type' => $policyType, + ]); + + $policyId = $this->extractCreatedPolicyId($response); + + return [ + 'attempted' => true, + 'success' => $response->successful(), + 'policy_id' => $policyId, + 'response' => $response, + ]; + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ @@ -1543,6 +1720,63 @@ private function prefixRestoredName(?string $name, string $fallback): string return $prefix.$base; } + /** + * @param array $payload + * @param array $originalPayload + * @return array + */ + private function applyRestoredNameToPayload(array $payload, array $originalPayload, string $fallbackName): array + { + $displayName = $this->resolvePayloadString($payload, ['displayName']); + $name = $this->resolvePayloadString($payload, ['name']); + $originalDisplayName = $this->resolvePayloadString($originalPayload, ['displayName']); + $originalName = $this->resolvePayloadString($originalPayload, ['name']); + $baseName = $displayName ?? $originalDisplayName ?? $name ?? $originalName ?? $fallbackName; + $restoredName = $this->prefixRestoredName($baseName, $fallbackName); + + if (array_key_exists('displayName', $payload) || $originalDisplayName !== null || $displayName !== null) { + $payload['displayName'] = $restoredName; + + return $payload; + } + + if (array_key_exists('name', $payload) || $originalName !== null || $name !== null) { + $payload['name'] = $restoredName; + + return $payload; + } + + $payload['displayName'] = $restoredName; + + return $payload; + } + + /** + * @param array $payload + * @param array $originalPayload + * @return array + */ + private function applyOdataTypeForCreate(string $policyType, array $payload, array $originalPayload): array + { + if (array_key_exists('@odata.type', $payload)) { + return $payload; + } + + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + + if ($odataType === null) { + return $payload; + } + + if (! $this->contracts->matchesTypeFamily($policyType, $odataType)) { + return $payload; + } + + $payload['@odata.type'] = $odataType; + + return $payload; + } + /** * @param array $payload * @param array $keys diff --git a/config/graph_contracts.php b/config/graph_contracts.php index b181301..774fa8e 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -136,7 +136,10 @@ 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], - 'allowed_expand' => [], + 'allowed_expand' => [ + 'scheduledActionsForRule', + 'scheduledActionsForRule($expand=scheduledActionConfigurations)', + ], 'type_family' => [ '#microsoft.graph.deviceCompliancePolicy', '#microsoft.graph.windows10CompliancePolicy', @@ -328,7 +331,7 @@ ], 'assignmentFilter' => [ 'resource' => 'deviceManagement/assignmentFilters', - 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', '@odata.type', 'roleScopeTagIds'], + 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceAndAppManagementAssignmentFilter', @@ -345,7 +348,7 @@ ], 'roleScopeTag' => [ 'resource' => 'deviceManagement/roleScopeTags', - 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'isBuiltIn'], + 'allowed_select' => ['id', 'displayName', 'description', 'isBuiltIn'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.roleScopeTag', @@ -362,7 +365,7 @@ ], 'notificationMessageTemplate' => [ 'resource' => 'deviceManagement/notificationMessageTemplates', - 'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', '@odata.type', 'lastModifiedDateTime'], + 'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', 'lastModifiedDateTime'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.notificationMessageTemplate', diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 42effc5..c57ebd1 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -1,35 +1,169 @@ @php $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []]; $summary = $diff['summary'] ?? []; + + $groupByBlock = static function (array $items): array { + $groups = []; + + foreach ($items as $path => $value) { + if (! is_string($path) || $path === '') { + continue; + } + + $parts = explode(' > ', $path, 2); + + if (count($parts) === 2) { + [$group, $label] = $parts; + } else { + $group = 'Other'; + $label = $path; + } + + $groups[$group][$label] = $value; + } + + ksort($groups); + + return $groups; + }; + + $stringify = static function (mixed $value): string { + if ($value === null) { + return '—'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; + }; + + $isExpandable = static function (mixed $value): bool { + if (is_array($value)) { + return true; + } + + return is_string($value) && strlen($value) > 160; + }; @endphp -
-
Normalized diff
-
- {{ $summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0) }} -
+
+ +
+ + {{ (int) ($summary['added'] ?? 0) }} added + + + {{ (int) ($summary['removed'] ?? 0) }} removed + + + {{ (int) ($summary['changed'] ?? 0) }} changed + +
+
- @foreach (['added' => 'Added', 'removed' => 'Removed', 'changed' => 'Changed'] as $key => $label) + @foreach (['changed' => ['label' => 'Changed', 'collapsed' => false], 'added' => ['label' => 'Added', 'collapsed' => true], 'removed' => ['label' => 'Removed', 'collapsed' => true]] as $key => $meta) @php $items = $diff[$key] ?? []; + $groups = $groupByBlock(is_array($items) ? $items : []); @endphp - @if (! empty($items)) -
-
{{ $label }}
-
    - @foreach ($items as $name => $value) -
  • - {{ $name }}: - @if (is_array($value)) -
    {{ json_encode($value, JSON_PRETTY_PRINT) }}
    - @else - {{ is_bool($value) ? ($value ? 'true' : 'false') : (string) $value }} - @endif -
  • + @if ($groups !== []) + +
    + @foreach ($groups as $group => $groupItems) +
    +
    +
    + {{ $group }} +
    + + {{ count($groupItems) }} + +
    + +
    + @foreach ($groupItems as $name => $value) +
    + @if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value)) + @php + $from = $value['from']; + $to = $value['to']; + $fromText = $stringify($from); + $toText = $stringify($to); + @endphp +
    +
    + {{ (string) $name }} +
    +
    + From + @if ($isExpandable($from)) +
    + + View + +
    {{ $fromText }}
    +
    + @else +
    {{ $fromText }}
    + @endif +
    +
    + To + @if ($isExpandable($to)) +
    + + View + +
    {{ $toText }}
    +
    + @else +
    {{ $toText }}
    + @endif +
    +
    + @else + @php + $text = $stringify($value); + @endphp +
    +
    + {{ (string) $name }} +
    +
    + @if ($isExpandable($value)) +
    + + View + +
    {{ $text }}
    +
    + @else +
    {{ $text }}
    + @endif +
    +
    + @endif +
    + @endforeach +
    +
    @endforeach -
-
+
+ @endif @endforeach
diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 4fb8987..49676ce 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -88,6 +88,31 @@ {{ $item['compliance_action_warning'] }} @endif + @if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary'])) + @php + $summary = $item['compliance_action_summary']; + $missingTemplates = $item['compliance_action_missing_templates'] ?? []; + $total = (int) ($summary['total'] ?? 0); + $missing = (int) ($summary['missing'] ?? 0); + @endphp + +
+ Compliance notifications: {{ $total }} total • {{ $missing }} missing +
+ + @if (! empty($missingTemplates) && is_array($missingTemplates)) +
+ Missing notification templates +
+ @foreach ($missingTemplates as $templateId) +
+ {{ $templateId }} +
+ @endforeach +
+
+ @endif + @endif @endforeach diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 6d288b9..38a6ce4 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -192,10 +192,10 @@ @if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary'])) @php $summary = $item['compliance_action_summary']; - $complianceOutcomes = $item['compliance_action_outcomes'] ?? []; - $complianceIssues = collect($complianceOutcomes) - ->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped') - ->values(); + $complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null) + ? $item['compliance_action_outcomes'] + : []; + $complianceEntries = collect($complianceOutcomes)->values(); @endphp
@@ -203,18 +203,26 @@ {{ (int) ($summary['skipped'] ?? 0) }} skipped
- @if ($complianceIssues->isNotEmpty()) + @if ($complianceEntries->isNotEmpty())
Compliance notification details
- @foreach ($complianceIssues as $outcome) + @foreach ($complianceEntries as $outcome) + @php + $outcomeStatus = $outcome['status'] ?? 'unknown'; + $outcomeColor = match ($outcomeStatus) { + 'mapped' => 'text-green-700 bg-green-100 border-green-200', + 'skipped' => 'text-amber-900 bg-amber-100 border-amber-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + @endphp
Template {{ $outcome['template_id'] ?? 'unknown' }}
- - skipped + + {{ $outcomeStatus }}
@if (! empty($outcome['rule_name'])) @@ -222,6 +230,11 @@ Rule: {{ $outcome['rule_name'] }}
@endif + @if (! empty($outcome['mapped_template_id'])) +
+ Mapped to: {{ $outcome['mapped_template_id'] }} +
+ @endif @if (! empty($outcome['reason']))
{{ $outcome['reason'] }} diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index f466235..2f7e0b7 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -1,22 +1,11 @@ -
+
@if($version->assignments && count($version->assignments) > 0) -
-
-
-
-

- Assignments -

-

- Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }} -

-
-
-
- -
- -
+ +
+

Summary

{{ count($version->assignments) }} assignment(s) @@ -29,12 +18,11 @@

- @php $scopeTags = $version->scope_tags['names'] ?? []; @endphp @if(!empty($scopeTags)) -
+

Scope Tags

@foreach($scopeTags as $tag) @@ -46,7 +34,6 @@
@endif -

Assignment Details

@@ -56,7 +43,7 @@ $type = $target['@odata.type'] ?? ''; $typeKey = strtolower((string) $type); $intent = $assignment['intent'] ?? 'apply'; - + $typeName = match (true) { str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group', str_contains($typeKey, 'groupassignmenttarget') => 'Include group', @@ -64,7 +51,7 @@ str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices', default => 'Unknown', }; - + $groupId = $target['groupId'] ?? null; $groupName = $target['group_display_name'] ?? null; $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); @@ -78,7 +65,7 @@
{{ $typeName }} - + @if($groupId) : @if($groupOrphaned) @@ -104,55 +91,83 @@ Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }} @endif - + ({{ $intent }})
@endforeach
-
+ @else -
-
-

- Assignments -

- @php - $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; - $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; - $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; - @endphp - @if($assignmentsFetchFailed) -

- Assignments could not be fetched from Microsoft Graph. -

- @if($assignmentsFetchError) -

- {{ $assignmentsFetchError }} -

- @endif - @elseif($assignmentsFetched) -

- No assignments found for this version. -

- @else -

- Assignments were not captured for this version. -

- @endif - @php - $hasBackupItem = $version->policy->backupItems() - ->whereNotNull('assignments') - ->where('created_at', '<=', $version->captured_at) - ->exists(); - @endphp - @if($hasBackupItem) -

- 💡 Assignment data may be available in related backup items. + + @php + $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; + $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; + $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; + @endphp + + @if($assignmentsFetchFailed) +

+ Assignments could not be fetched from Microsoft Graph. +

+ @if($assignmentsFetchError) +

+ {{ $assignmentsFetchError }}

@endif + @elseif($assignmentsFetched) +

+ No assignments found for this version. +

+ @else +

+ Assignments were not captured for this version. +

+ @endif + + @php + $hasBackupItem = $version->policy->backupItems() + ->whereNotNull('assignments') + ->where('created_at', '<=', $version->captured_at) + ->exists(); + @endphp + @if($hasBackupItem) +

+ 💡 Assignment data may be available in related backup items. +

+ @endif + + @endif + + @php + $complianceTotal = $compliance['total'] ?? 0; + $complianceTemplates = $compliance['templates'] ?? []; + @endphp + @if($complianceTotal > 0) + +
+ @foreach($compliance['items'] ?? [] as $item) + @php + $ruleName = $item['rule_name'] ?? null; + $templateId = $item['template_id'] ?? null; + @endphp +
+ + + {{ $ruleName ?: 'Default rule' }} + + @if($templateId) + + Template: {{ $templateId }} + + @endif +
+ @endforeach
-
+ @endif
diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index e5a8981..a9f3c35 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -41,7 +41,7 @@ ## Phase 3: Restore Logic and Mapping **Purpose**: Restore new policy types safely using assignment and foundation mappings. -- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. +- [x] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. - [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. - [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. - [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. @@ -54,8 +54,8 @@ ## Phase 4: Admin UX **Purpose**: Surface restore and compliance details clearly in the UI. -- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings. -- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons. +- [x] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings. +- [x] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons. **Checkpoint**: Admins can see compliance related mapping results in preview and results. diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 706e2a0..7e873b7 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -392,3 +392,105 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($run->results[0]['status'])->toBe('applied'); expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); }); + +test('restore execution creates missing policy using contracts', function () { + $graphClient = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + public int $createCalls = 0; + + public array $createPayloads = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(false, [], 404, [], [], [ + 'error_code' => 'ResourceNotFound', + 'error_message' => 'Resource not found.', + ]); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'POST' && $path === 'deviceManagement/deviceCompliancePolicies') { + $this->createCalls++; + $this->createPayloads[] = $options['json'] ?? []; + + return new GraphResponse(true, ['id' => 'compliance-created']); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $graphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-4', + 'name' => 'Tenant Four', + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'compliance-1', + 'policy_type' => 'deviceCompliancePolicy', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'displayName' => 'Compliance Policy', + 'description' => 'Test policy', + ], + ]) + ->create(); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($graphClient->applyCalls)->toBe(1); + expect($graphClient->createCalls)->toBe(1); + expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy'); + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['created_policy_id'])->toBe('compliance-created'); +}); diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index fc441dc..a4ccb0a 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -188,4 +188,5 @@ public function request(string $method, string $path, array $options = []): Grap expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull(); expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1); + expect($policyPreview['compliance_action_missing_templates'] ?? [])->toContain('template-1'); }); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index af5de47..ff174ad 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -112,3 +112,90 @@ $response->assertOk(); $response->assertSee('No assignments found for this version'); }); + +it('shows compliance notifications when present', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceCompliancePolicy', + 'assignments' => null, + 'snapshot' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Test rule', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-123', + ], + ], + ], + ], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('Compliance notifications'); + $response->assertSee('Test rule'); + $response->assertSee('template-123'); +}); + +it('uses a default label when compliance rule name is missing', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceCompliancePolicy', + 'assignments' => null, + 'snapshot' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => null, + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-456', + ], + ], + ], + ], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('Compliance notifications'); + $response->assertSee('Default rule'); + $response->assertSee('template-456'); +}); + +it('renders structured normalized settings for compliance policy versions', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceCompliancePolicy', + 'platform' => 'all', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'passwordRequired' => true, + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}?tab=normalized-settings"); + + $response->assertOk(); + $response->assertSee('Password & Access'); + $response->assertSee('Password required'); + $response->assertSee('Enabled'); +}); diff --git a/tests/Unit/CompliancePolicyNormalizerTest.php b/tests/Unit/CompliancePolicyNormalizerTest.php index a2c5bca..2ef9b53 100644 --- a/tests/Unit/CompliancePolicyNormalizerTest.php +++ b/tests/Unit/CompliancePolicyNormalizerTest.php @@ -15,6 +15,15 @@ 'bitLockerEnabled' => false, 'osMinimumVersion' => '10.0.19045', 'activeFirewallRequired' => true, + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Default rule', + 'scheduledActionConfigurations' => [ + ['actionType' => 'notification'], + ], + ], + ], + 'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies', 'customSetting' => 'Custom value', ]; @@ -31,6 +40,50 @@ expect($additionalBlock)->not->toBeNull(); expect(collect($additionalBlock['rows'])->pluck('label')->all()) ->toContain('Custom Setting'); + expect(collect($additionalBlock['rows'])->pluck('label')->all()) + ->not->toContain('Scheduled Actions For Rule'); + expect(collect($additionalBlock['rows'])->pluck('label')->all()) + ->not->toContain('Scheduled Actions For Rule@Odata.context'); expect($settings->pluck('title')->all())->not->toContain('General'); }); + +it('flattens compliance notifications into a compact diff key', function () { + $normalizer = app(CompliancePolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'passwordRequired' => true, + 'scheduledActionsForRule' => [ + [ + 'ruleName' => null, + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-123', + ], + [ + 'actionType' => 'notification', + 'notificationTemplateId' => '00000000-0000-0000-0000-000000000000', + ], + [ + 'actionType' => 'block', + 'notificationTemplateId' => 'template-ignored', + ], + ], + ], + ], + 'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies', + ]; + + $flat = $normalizer->flattenForDiff($snapshot, 'deviceCompliancePolicy', 'windows'); + + expect($flat)->toHaveKey('Password & Access > Password required'); + expect($flat['Password & Access > Password required'])->toBeTrue(); + + expect($flat)->toHaveKey('Compliance notifications > Template IDs'); + expect($flat['Compliance notifications > Template IDs'])->toBe(['template-123']); + + expect(array_keys($flat))->not->toContain('scheduledActionsForRule'); + expect(array_keys($flat))->not->toContain('scheduledActionsForRule@odata.context'); +}); diff --git a/tests/Unit/DefaultPolicyNormalizerDiffTest.php b/tests/Unit/DefaultPolicyNormalizerDiffTest.php new file mode 100644 index 0000000..8774a84 --- /dev/null +++ b/tests/Unit/DefaultPolicyNormalizerDiffTest.php @@ -0,0 +1,21 @@ + '#microsoft.graph.somePolicy', + 'displayName' => 'Example Policy', + 'customSetting' => true, + ]; + + $flat = $normalizer->flattenForDiff($snapshot, 'somePolicyType', 'all'); + + expect($flat)->toHaveKey('General > Display Name', 'Example Policy'); + expect($flat)->toHaveKey('General > Custom Setting'); + expect($flat['General > Custom Setting'])->toBeTrue(); +}); diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index 813aecf..d032deb 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -93,3 +93,19 @@ expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled'); expect($sanitized)->not->toHaveKey('locale'); }); + +it('exposes compliance policy expand for scheduled actions', function () { + $contract = $this->registry->get('deviceCompliancePolicy'); + + expect($contract)->not->toBeEmpty(); + expect($contract['allowed_expand'] ?? []) + ->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)'); +}); + +it('omits role scope tags from assignment filter selects', function () { + $contract = $this->registry->get('assignmentFilter'); + + expect($contract)->not->toBeEmpty(); + expect($contract['allowed_select'] ?? []) + ->not->toContain('roleScopeTagIds'); +}); diff --git a/tests/Unit/GraphContractRegistryTest.php b/tests/Unit/GraphContractRegistryTest.php index 8b35555..cab3469 100644 --- a/tests/Unit/GraphContractRegistryTest.php +++ b/tests/Unit/GraphContractRegistryTest.php @@ -42,8 +42,8 @@ $query = $result['query']; $warnings = $result['warnings']; - expect($query['$select'])->toBe(['id', 'displayName']); - expect($query['$expand'])->toBe(['assignments']); + expect($query['$select'])->toBe('id,displayName'); + expect($query['$expand'])->toBe('assignments'); expect($warnings)->not->toBeEmpty(); }); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php new file mode 100644 index 0000000..93cbed1 --- /dev/null +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -0,0 +1,109 @@ +requests[] = ['getPolicy', $policyType, $policyId, $options]; + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Compliance Alpha', + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + ], + ]); + } + + 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]; + + if (str_contains($path, 'scheduledActionsForRule')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'ruleName' => 'Default rule', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-123', + ], + ], + ], + ], + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + +it('hydrates compliance policy scheduled actions into snapshots', function () { + $client = new PolicySnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-compliance', + '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' => 'compliance-123', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Alpha', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('scheduledActionsForRule'); + expect($result['payload']['scheduledActionsForRule'])->toHaveCount(1); + expect($result['payload']['scheduledActionsForRule'][0]['scheduledActionConfigurations'][0]['notificationTemplateId']) + ->toBe('template-123'); + expect($result['metadata']['compliance_actions_hydration'])->toBe('complete'); + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('deviceCompliancePolicy'); + expect($client->requests[0][2])->toBe('compliance-123'); + expect($client->requests[0][3]['expand'] ?? null) + ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); +});