'#microsoft.graph.deviceAndAppManagementRoleDefinition', 'displayName' => 'Policy and Profile Manager', 'description' => 'Built-in RBAC role', 'isBuiltIn' => true, 'rolePermissions' => [ [ 'resourceActions' => [ [ 'allowedResourceActions' => [ 'Microsoft.Intune/deviceConfigurations/read', 'Microsoft.Intune/deviceConfigurations/create', ], 'notAllowedResourceActions' => [ 'Microsoft.Intune/deviceConfigurations/delete', ], ], ], ], ], 'roleScopeTagIds' => ['scope-1', '0'], ]; $result = $normalizer->normalize($snapshot, 'intuneRoleDefinition', 'all'); $summary = collect($result['settings'])->firstWhere('title', 'Role definition'); $permissionBlock = collect($result['settings'])->firstWhere('title', 'Permission block 1'); $summaryEntries = collect($summary['entries'] ?? [])->keyBy('key'); $permissionEntries = collect($permissionBlock['entries'] ?? [])->keyBy('key'); expect($result['status'])->toBe('ok'); expect($summaryEntries['Role source']['value'] ?? null)->toBe('Built-in'); expect($summaryEntries['Permission blocks']['value'] ?? null)->toBe(1); expect($summaryEntries['Scope tag IDs']['value'] ?? null)->toBe(['scope-1', '0']); expect($permissionEntries['Allowed actions']['value'] ?? null)->toBe([ 'Microsoft.Intune/deviceConfigurations/create', 'Microsoft.Intune/deviceConfigurations/read', ]); expect($permissionEntries['Denied actions']['value'] ?? null)->toBe([ 'Microsoft.Intune/deviceConfigurations/delete', ]); }); it('flattens custom intune role definitions deterministically regardless of permission block order', function (): void { $normalizer = app(IntuneRoleDefinitionNormalizer::class); $firstSnapshot = [ 'displayName' => 'Custom RBAC Role', 'isBuiltIn' => false, 'rolePermissions' => [ [ 'resourceActions' => [ [ 'allowedResourceActions' => [ 'Microsoft.Intune/deviceCompliancePolicies/read', ], ], ], ], [ 'resourceActions' => [ [ 'allowedResourceActions' => [ 'Microsoft.Intune/deviceConfigurations/read', ], 'condition' => '@Resource[Microsoft.Intune/deviceConfigurations] Exists', ], ], ], ], ]; $secondSnapshot = [ 'displayName' => 'Custom RBAC Role', 'isBuiltIn' => false, 'rolePermissions' => [ $firstSnapshot['rolePermissions'][1], $firstSnapshot['rolePermissions'][0], ], ]; expect($normalizer->flattenForDiff($firstSnapshot, 'intuneRoleDefinition', 'all')) ->toBe($normalizer->flattenForDiff($secondSnapshot, 'intuneRoleDefinition', 'all')); }); it('classifies metadata-only role definition changes separately from permission changes', function (): void { $normalizer = app(IntuneRoleDefinitionNormalizer::class); $baselineSnapshot = [ 'displayName' => 'Custom RBAC Role', 'description' => 'Baseline description', 'isBuiltIn' => false, 'rolePermissions' => [ [ 'resourceActions' => [ [ 'allowedResourceActions' => [ 'Microsoft.Intune/deviceConfigurations/read', ], ], ], ], ], 'roleScopeTagIds' => ['0'], ]; $metadataOnlySnapshot = $baselineSnapshot; $metadataOnlySnapshot['description'] = 'Updated description'; $permissionChangedSnapshot = $baselineSnapshot; $permissionChangedSnapshot['rolePermissions'][0]['resourceActions'][0]['allowedResourceActions'][] = 'Microsoft.Intune/deviceConfigurations/delete'; $metadataDiff = $normalizer->classifyDiff($baselineSnapshot, $metadataOnlySnapshot, 'all'); $permissionDiff = $normalizer->classifyDiff($baselineSnapshot, $permissionChangedSnapshot, 'all'); expect($metadataDiff['diff_kind'])->toBe('metadata_only'); expect($metadataDiff['changed_keys'])->toBe([ 'Role definition > Description', ]); expect($metadataDiff['metadata_keys'])->toBe([ 'Role definition > Description', ]); expect($metadataDiff['permission_keys'])->toBe([]); expect($metadataDiff['diff_fingerprint'])->not->toBe($permissionDiff['diff_fingerprint']); expect($permissionDiff['diff_kind'])->toBe('permission_change'); expect($permissionDiff['changed_keys'])->toContain('Permission block 1 > Allowed actions'); expect($permissionDiff['permission_keys'])->toContain('Permission block 1 > Allowed actions'); });