build(rbacBuilderEvidenceFixture()); expect(array_map( static fn ($row): string => $row->key, $presentation->rows, ))->toBe([ 'Role definition > Display name', 'Role definition > Description', 'Role definition > Role source', 'Role definition > Permission blocks', 'Role definition > Scope tag IDs', 'Permission block 1 > Allowed actions', 'Permission block 1 > Denied actions', 'Permission block 1 > Conditions', ]); $rows = collect($presentation->rows)->keyBy('key'); expect($presentation->summary->changedCount)->toBe(2) ->and($presentation->summary->addedCount)->toBe(1) ->and($presentation->summary->removedCount)->toBe(1) ->and($presentation->summary->unchangedCount)->toBe(4) ->and($presentation->summary->message)->toBeNull() ->and($rows->get('Role definition > Description')?->status)->toBe(DiffRowStatus::Changed) ->and($rows->get('Permission block 1 > Allowed actions')?->status)->toBe(DiffRowStatus::Changed) ->and($rows->get('Permission block 1 > Denied actions')?->status)->toBe(DiffRowStatus::Removed) ->and($rows->get('Permission block 1 > Conditions')?->status)->toBe(DiffRowStatus::Added) ->and($rows->get('Permission block 1 > Allowed actions')?->isListLike)->toBeTrue() ->and($rows->get('Permission block 1 > Allowed actions')?->addedItems)->toBe([ 'Microsoft.Intune/deviceConfigurations/create', ]) ->and($rows->get('Permission block 1 > Allowed actions')?->removedItems)->toBe([ 'Microsoft.Intune/deviceConfigurations/delete', ]) ->and($rows->get('Permission block 1 > Allowed actions')?->unchangedItems)->toBe([ 'Microsoft.Intune/deviceConfigurations/read', ]); }); it('preserves null boolean scalar and empty-list values for shared formatting', function (): void { $presentation = app(RbacRoleDefinitionDiffBuilder::class)->build(rbacBuilderEvidenceFixture([ 'changed_keys' => [ 'Role definition > Description', 'Role definition > Preview enabled', 'Role definition > Scope tag IDs', ], 'baseline' => [ 'normalized' => [ 'Role definition > Description' => null, 'Role definition > Preview enabled' => false, 'Role definition > Scope tag IDs' => [], ], 'is_built_in' => false, 'role_permission_count' => 0, ], 'current' => [ 'normalized' => [ 'Role definition > Description' => 'Updated description', 'Role definition > Preview enabled' => true, 'Role definition > Scope tag IDs' => ['scope-1'], ], 'is_built_in' => false, 'role_permission_count' => 0, ], ])); $rows = collect($presentation->rows)->keyBy('key'); expect($rows->get('Role definition > Description')?->oldValue)->toBeNull() ->and($rows->get('Role definition > Description')?->newValue)->toBe('Updated description') ->and($rows->get('Role definition > Preview enabled')?->oldValue)->toBeFalse() ->and($rows->get('Role definition > Preview enabled')?->newValue)->toBeTrue() ->and($rows->get('Role definition > Scope tag IDs')?->oldValue)->toBe([]) ->and($rows->get('Role definition > Scope tag IDs')?->newValue)->toBe(['scope-1']) ->and($rows->get('Role definition > Scope tag IDs')?->isListLike)->toBeTrue() ->and($rows->get('Role definition > Scope tag IDs')?->addedItems)->toBe(['scope-1']) ->and($rows->get('Role definition > Scope tag IDs')?->removedItems)->toBe([]) ->and($rows->get('Role definition > Role source')?->newValue)->toBe('Custom') ->and($rows->get('Role definition > Permission blocks')?->newValue)->toBe(0); }); it('derives identical fallback rows into a no-change summary when normalized metadata is sparse', function (): void { $presentation = app(RbacRoleDefinitionDiffBuilder::class)->build([ 'changed_keys' => [], 'baseline' => [ 'normalized' => [], 'is_built_in' => true, 'role_permission_count' => 1, ], 'current' => [ 'normalized' => [], 'is_built_in' => true, 'role_permission_count' => 1, ], ]); expect(array_map( static fn ($row): string => $row->key, $presentation->rows, ))->toBe([ 'Role definition > Role source', 'Role definition > Permission blocks', ]) ->and($presentation->summary->changedCount)->toBe(0) ->and($presentation->summary->addedCount)->toBe(0) ->and($presentation->summary->removedCount)->toBe(0) ->and($presentation->summary->unchangedCount)->toBe(2) ->and($presentation->summary->message)->toBe('No changes detected.'); }); it('returns a no-data presentation for empty or invalid RBAC payloads', function (): void { $presentation = app(RbacRoleDefinitionDiffBuilder::class)->build([ 'changed_keys' => ['Ghost key'], 'baseline' => ['normalized' => ['' => 'ignored']], 'current' => ['normalized' => [' ' => 'ignored']], ]); expect($presentation->rows)->toBe([]) ->and($presentation->summary->hasRows)->toBeFalse() ->and($presentation->summary->changedCount)->toBe(0) ->and($presentation->summary->addedCount)->toBe(0) ->and($presentation->summary->removedCount)->toBe(0) ->and($presentation->summary->unchangedCount)->toBe(0) ->and($presentation->summary->message)->toBe('No diff data available.'); }); /** * @param array $overrides * @return array */ function rbacBuilderEvidenceFixture(array $overrides = []): array { return rbacBuilderFixtureMerge([ 'changed_keys' => [ 'Role definition > Description', 'Permission block 1 > Allowed actions', ], 'baseline' => [ 'normalized' => [ 'Role definition > Display name' => 'Security Reader', 'Role definition > Description' => 'Baseline description', 'Role definition > Scope tag IDs' => ['0', 'scope-1'], 'Permission block 1 > Allowed actions' => [ 'Microsoft.Intune/deviceConfigurations/delete', 'Microsoft.Intune/deviceConfigurations/read', ], 'Permission block 1 > Denied actions' => [ 'Microsoft.Intune/deviceConfigurations/wipe', ], ], 'is_built_in' => false, 'role_permission_count' => 1, ], 'current' => [ 'normalized' => [ 'Role definition > Display name' => 'Security Reader', 'Role definition > Description' => 'Updated description', 'Role definition > Scope tag IDs' => ['0', 'scope-1'], 'Permission block 1 > Allowed actions' => [ 'Microsoft.Intune/deviceConfigurations/create', 'Microsoft.Intune/deviceConfigurations/read', ], 'Permission block 1 > Conditions' => [ '@Resource[Microsoft.Intune/deviceConfigurations] Exists', ], ], 'is_built_in' => false, 'role_permission_count' => 1, ], ], $overrides); } /** * @param array $base * @param array $overrides * @return array */ function rbacBuilderFixtureMerge(array $base, array $overrides): array { foreach ($overrides as $key => $value) { if ($key === 'normalized') { $base[$key] = $value; continue; } if ( is_string($key) && array_key_exists($key, $base) && is_array($value) && is_array($base[$key]) && ! array_is_list($value) && ! array_is_list($base[$key]) ) { $base[$key] = rbacBuilderFixtureMerge($base[$key], $value); continue; } $base[$key] = $value; } return $base; }