fix: make policy version diffs readable

This commit is contained in:
Ahmed Darrazi 2025-12-29 03:04:45 +01:00
parent 02dc55b5d4
commit 48124e1fd0
10 changed files with 528 additions and 134 deletions

View File

@ -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 (`<x-filament::section>`, 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.

View File

@ -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,
];
}),
]),
]),
]);

View File

@ -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<string, mixed>
*/
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<int, string>, labels?: array<string, string>}
*/
@ -282,6 +366,7 @@ private function ignoredKeys(): array
'settingCount',
'settingsCount',
'templateReference',
'scheduledActionsForRule@odata.context',
'scheduledActionsForRule',
];
}

View File

@ -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<int, array<string, mixed>>, settings_table?: array<string, mixed>} $normalized
* @return array<string, mixed>
*/
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);

View File

@ -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);
}
/**

View File

@ -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
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-800">Normalized diff</div>
<div class="text-xs text-gray-600">
{{ $summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0) }}
</div>
<div class="space-y-4">
<x-filament::section
heading="Normalized diff"
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
>
<div class="flex flex-wrap gap-2">
<x-filament::badge color="success">
{{ (int) ($summary['added'] ?? 0) }} added
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($summary['removed'] ?? 0) }} removed
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($summary['changed'] ?? 0) }} changed
</x-filament::badge>
</div>
</x-filament::section>
@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))
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">{{ $label }}</div>
<ul class="mt-1 space-y-1">
@foreach ($items as $name => $value)
<li class="rounded border border-gray-100 bg-gray-50 p-2 text-sm text-gray-800">
<span class="font-medium">{{ $name }}</span>:
@if (is_array($value))
<pre class="mt-1 overflow-x-auto text-xs">{{ json_encode($value, JSON_PRETTY_PRINT) }}</pre>
@else
<span>{{ is_bool($value) ? ($value ? 'true' : 'false') : (string) $value }}</span>
@endif
</li>
@if ($groups !== [])
<x-filament::section
:heading="$meta['label']"
collapsible
:collapsed="$meta['collapsed']"
>
<div class="space-y-6">
@foreach ($groups as $group => $groupItems)
<div>
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $group }}
</div>
<x-filament::badge size="sm" color="gray">
{{ count($groupItems) }}
</x-filament::badge>
</div>
<div class="mt-2 divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-white/10 dark:border-white/10">
@foreach ($groupItems as $name => $value)
<div class="px-4 py-3">
@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
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</span>
@if ($isExpandable($from))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $fromText }}</pre>
</details>
@else
<div class="mt-1">{{ $fromText }}</div>
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</span>
@if ($isExpandable($to))
<details class="mt-1">
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $toText }}</pre>
</details>
@else
<div class="mt-1">{{ $toText }}</div>
@endif
</div>
</div>
@else
@php
$text = $stringify($value);
@endphp
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ (string) $name }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200 sm:max-w-[70%]">
@if ($isExpandable($value))
<details>
<summary class="cursor-pointer text-sm text-gray-700 dark:text-gray-200">
View
</summary>
<pre class="mt-2 overflow-x-auto text-xs text-gray-800 dark:text-gray-200">{{ $text }}</pre>
</details>
@else
<div class="break-words">{{ $text }}</div>
@endif
</div>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
</ul>
</div>
</div>
</x-filament::section>
@endif
@endforeach
</div>

View File

@ -1,22 +1,11 @@
<div class="fi-section">
<div class="space-y-4">
@if($version->assignments && count($version->assignments) > 0)
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }}
</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 dark:border-white/10">
<!-- Summary -->
<div class="mb-4">
<x-filament::section
heading="Assignments"
:description="'Captured with this version on ' . $version->captured_at->format('M d, Y H:i')"
>
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Summary</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ count($version->assignments) }} assignment(s)
@ -29,12 +18,11 @@
</p>
</div>
<!-- Scope Tags -->
@php
$scopeTags = $version->scope_tags['names'] ?? [];
@endphp
@if(!empty($scopeTags))
<div class="mb-4">
<div>
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Scope Tags</h4>
<div class="mt-2 flex flex-wrap gap-2">
@foreach($scopeTags as $tag)
@ -46,7 +34,6 @@
</div>
@endif
<!-- Assignment Details -->
<div>
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
<div class="mt-2 space-y-2">
@ -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 @@
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
@if($groupId)
<span class="text-gray-600 dark:text-gray-400">:</span>
@if($groupOrphaned)
@ -104,56 +91,52 @@
Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
</span>
@endif
<span class="ml-auto text-xs text-gray-500 dark:text-gray-500">({{ $intent }})</span>
</div>
@endforeach
</div>
</div>
</div>
</div>
</x-filament::section>
@else
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
@php
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
$assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false;
$assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null;
@endphp
@if($assignmentsFetchFailed)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments could not be fetched from Microsoft Graph.
</p>
@if($assignmentsFetchError)
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
{{ $assignmentsFetchError }}
</p>
@endif
@elseif($assignmentsFetched)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
No assignments found for this version.
</p>
@else
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
<x-filament::section heading="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)
<p class="text-sm text-gray-500 dark:text-gray-400">
Assignments could not be fetched from Microsoft Graph.
</p>
@if($assignmentsFetchError)
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
{{ $assignmentsFetchError }}
</p>
@endif
@php
$hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments')
->where('created_at', '<=', $version->captured_at)
->exists();
@endphp
@if($hasBackupItem)
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
💡 Assignment data may be available in related backup items.
</p>
@endif
</div>
</div>
@elseif($assignmentsFetched)
<p class="text-sm text-gray-500 dark:text-gray-400">
No assignments found for this version.
</p>
@else
<p class="text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@endif
@php
$hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments')
->where('created_at', '<=', $version->captured_at)
->exists();
@endphp
@if($hasBackupItem)
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
💡 Assignment data may be available in related backup items.
</p>
@endif
</x-filament::section>
@endif
@php
@ -161,40 +144,29 @@
$complianceTemplates = $compliance['templates'] ?? [];
@endphp
@if($complianceTotal > 0)
<div class="mt-6 rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Compliance notifications
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $complianceTotal }} action(s) {{ count($complianceTemplates) }} template(s)
</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 dark:border-white/10">
<div class="space-y-2">
@foreach($compliance['items'] ?? [] as $item)
@php
$ruleName = $item['rule_name'] ?? null;
$templateId = $item['template_id'] ?? null;
@endphp
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-white">
{{ $ruleName ?: 'Unnamed rule' }}
<x-filament::section
heading="Compliance notifications"
:description="$complianceTotal . ' action(s) • ' . count($complianceTemplates) . ' template(s)'"
>
<div class="space-y-2">
@foreach($compliance['items'] ?? [] as $item)
@php
$ruleName = $item['rule_name'] ?? null;
$templateId = $item['template_id'] ?? null;
@endphp
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-white">
{{ $ruleName ?: 'Default rule' }}
</span>
@if($templateId)
<span class="text-xs text-gray-500 dark:text-gray-500">
Template: {{ $templateId }}
</span>
@if($templateId)
<span class="text-xs text-gray-500 dark:text-gray-500">
Template: {{ $templateId }}
</span>
@endif
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</div>
</x-filament::section>
@endif
</div>

View File

@ -144,3 +144,58 @@
$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');
});

View File

@ -23,6 +23,7 @@
],
],
],
'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies',
'customSetting' => 'Custom value',
];
@ -41,6 +42,48 @@
->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');
});

View File

@ -0,0 +1,21 @@
<?php
use App\Services\Intune\DefaultPolicyNormalizer;
uses(Tests\TestCase::class);
it('flattens normalized settings with section prefixes for diffs', function () {
$normalizer = app(DefaultPolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#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();
});