'def-ga-001', 'displayName' => 'Global Administrator', 'templateId' => '62e90394-69f5-4237-9190-012177145e10', 'isBuiltIn' => true, ], [ 'id' => 'def-ua-002', 'displayName' => 'User Administrator', 'templateId' => 'fe930be7-5e62-47db-91af-98c3a49a38b1', 'isBuiltIn' => true, ], [ 'id' => 'def-reader-003', 'displayName' => 'Directory Readers', 'templateId' => '88d8e3e3-8f55-4a1e-953a-9b9898b87601', 'isBuiltIn' => true, ], ]; } function sampleRoleAssignments(): array { return [ [ 'id' => 'assign-1', 'roleDefinitionId' => 'def-ga-001', 'principalId' => 'user-aaa', 'directoryScopeId' => '/', 'principal' => [ '@odata.type' => '#microsoft.graph.user', 'displayName' => 'Alice Admin', ], ], [ 'id' => 'assign-2', 'roleDefinitionId' => 'def-ua-002', 'principalId' => 'user-bbb', 'directoryScopeId' => '/', 'principal' => [ '@odata.type' => '#microsoft.graph.user', 'displayName' => 'Bob Useradmin', ], ], [ 'id' => 'assign-3', 'roleDefinitionId' => 'def-reader-003', 'principalId' => 'group-ccc', 'directoryScopeId' => '/', 'principal' => [ '@odata.type' => '#microsoft.graph.group', 'displayName' => 'Readers Group', ], ], ]; } /** * Build a mock GraphClientInterface that returns specific data for entra types. */ function buildGraphMock( array $roleDefinitions, array $roleAssignments, bool $failDefinitions = false, bool $failAssignments = false, ): GraphClientInterface { return new class($roleDefinitions, $roleAssignments, $failDefinitions, $failAssignments) implements GraphClientInterface { public function __construct( private readonly array $roleDefinitions, private readonly array $roleAssignments, private readonly bool $failDefinitions, private readonly bool $failAssignments, ) {} public function listPolicies(string $policyType, array $options = []): GraphResponse { return match ($policyType) { 'entraRoleDefinitions' => new GraphResponse( success: ! $this->failDefinitions, data: $this->failDefinitions ? [] : $this->roleDefinitions, status: $this->failDefinitions ? 403 : 200, errors: $this->failDefinitions ? ['Forbidden'] : [], ), 'entraRoleAssignments' => new GraphResponse( success: ! $this->failAssignments, data: $this->failAssignments ? [] : $this->roleAssignments, status: $this->failAssignments ? 403 : 200, errors: $this->failAssignments ? ['Forbidden'] : [], ), default => new GraphResponse(success: false, status: 404, errors: ['Unknown type']), }; } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { return new GraphResponse(success: false, status: 501); } public function getOrganization(array $options = []): GraphResponse { return new GraphResponse(success: false, status: 501); } public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse { return new GraphResponse(success: false, status: 501); } public function getServicePrincipalPermissions(array $options = []): GraphResponse { return new GraphResponse(success: false, status: 501); } public function request(string $method, string $path, array $options = []): GraphResponse { return new GraphResponse(success: false, status: 501); } }; } function buildReportService(GraphClientInterface $graphMock): EntraAdminRolesReportService { return new EntraAdminRolesReportService( graphClient: $graphMock, catalog: new HighPrivilegeRoleCatalog, graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class), ); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- it('creates a new report with correct attributes', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments())); $result = $service->generate($tenant); expect($result->created)->toBeTrue() ->and($result->storedReportId)->toBeInt() ->and($result->fingerprint)->toBeString()->toHaveLength(64) ->and($result->payload)->toBeArray(); $report = StoredReport::find($result->storedReportId); expect($report)->not->toBeNull() ->and($report->report_type)->toBe(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->and($report->tenant_id)->toBe((int) $tenant->getKey()) ->and($report->fingerprint)->toBe($result->fingerprint) ->and($report->previous_fingerprint)->toBeNull(); }); it('deduplicates when fingerprint matches latest report', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments())); $first = $service->generate($tenant); expect($first->created)->toBeTrue(); $second = $service->generate($tenant); expect($second->created)->toBeFalse() ->and($second->storedReportId)->toBe($first->storedReportId) ->and($second->fingerprint)->toBe($first->fingerprint); $count = StoredReport::query() ->where('tenant_id', $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->count(); expect($count)->toBe(1); }); it('chains previous_fingerprint when data changes', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $originalAssignments = sampleRoleAssignments(); $service1 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $originalAssignments)); $first = $service1->generate($tenant); // Add a new assignment to produce a different fingerprint $changedAssignments = array_merge($originalAssignments, [[ 'id' => 'assign-new', 'roleDefinitionId' => 'def-ga-001', 'principalId' => 'user-zzz', 'directoryScopeId' => '/', 'principal' => [ '@odata.type' => '#microsoft.graph.user', 'displayName' => 'Zach New', ], ]]); $service2 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $changedAssignments)); $second = $service2->generate($tenant); expect($second->created)->toBeTrue() ->and($second->fingerprint)->not->toBe($first->fingerprint) ->and(StoredReport::find($second->storedReportId)->previous_fingerprint)->toBe($first->fingerprint); }); it('throws when role definitions fetch fails', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $service = buildReportService(buildGraphMock([], [], failDefinitions: true)); $service->generate($tenant); })->throws(RuntimeException::class, 'Failed to fetch Entra role definitions'); it('throws when role assignments fetch fails', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $service = buildReportService(buildGraphMock(sampleRoleDefinitions(), [], failAssignments: true)); $service->generate($tenant); })->throws(RuntimeException::class, 'Failed to fetch Entra role assignments'); it('produces expected payload structure', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $service = buildReportService(buildGraphMock(sampleRoleDefinitions(), sampleRoleAssignments())); $result = $service->generate($tenant); $payload = $result->payload; expect($payload) ->toHaveKeys(['provider_key', 'domain', 'measured_at', 'role_definitions', 'role_assignments', 'totals', 'high_privilege']) ->and($payload['provider_key'])->toBe('microsoft') ->and($payload['domain'])->toBe('entra.admin_roles') ->and($payload['role_definitions'])->toHaveCount(3) ->and($payload['role_assignments'])->toHaveCount(3) ->and($payload['totals']['roles_total'])->toBe(3) ->and($payload['totals']['assignments_total'])->toBe(3) // Only GA is in the high-privilege catalog ->and($payload['totals']['high_privilege_assignments'])->toBe(1) ->and($payload['high_privilege'])->toHaveCount(1); // Verify first high-privilege entry shape $ga = collect($payload['high_privilege'])->firstWhere('role_template_id', '62e90394-69f5-4237-9190-012177145e10'); expect($ga)->not->toBeNull() ->and($ga['role_display_name'])->toBe('Global Administrator') ->and($ga['principal_id'])->toBe('user-aaa') ->and($ga['principal_type'])->toBe('user') ->and($ga['severity'])->toBe('critical'); }); it('computes deterministic fingerprint regardless of assignment order', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $assignments = sampleRoleAssignments(); $reversedAssignments = array_reverse($assignments); $service1 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $assignments)); $result1 = $service1->generate($tenant); // Delete the report to allow re-creation StoredReport::query()->where('id', $result1->storedReportId)->delete(); $service2 = buildReportService(buildGraphMock(sampleRoleDefinitions(), $reversedAssignments)); $result2 = $service2->generate($tenant); expect($result2->fingerprint)->toBe($result1->fingerprint); }); it('handles zero assignments gracefully', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $service = buildReportService(buildGraphMock(sampleRoleDefinitions(), [])); $result = $service->generate($tenant); expect($result->created)->toBeTrue() ->and($result->payload['totals']['assignments_total'])->toBe(0) ->and($result->payload['totals']['high_privilege_assignments'])->toBe(0) ->and($result->payload['high_privilege'])->toBeEmpty(); }); it('resolves principal types correctly', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $assignments = [ [ 'id' => 'a1', 'roleDefinitionId' => 'def-ga-001', 'principalId' => 'p-user', 'directoryScopeId' => '/', 'principal' => ['@odata.type' => '#microsoft.graph.user', 'displayName' => 'User'], ], [ 'id' => 'a2', 'roleDefinitionId' => 'def-ga-001', 'principalId' => 'p-group', 'directoryScopeId' => '/', 'principal' => ['@odata.type' => '#microsoft.graph.group', 'displayName' => 'Group'], ], [ 'id' => 'a3', 'roleDefinitionId' => 'def-ga-001', 'principalId' => 'p-sp', 'directoryScopeId' => '/', 'principal' => ['@odata.type' => '#microsoft.graph.servicePrincipal', 'displayName' => 'SP'], ], [ 'id' => 'a4', 'roleDefinitionId' => 'def-ga-001', 'principalId' => 'p-unknown', 'directoryScopeId' => '/', 'principal' => ['displayName' => 'NoType'], ], ]; $service = buildReportService(buildGraphMock(sampleRoleDefinitions(), $assignments)); $result = $service->generate($tenant); $types = collect($result->payload['high_privilege'])->pluck('principal_type')->toArray(); expect($types)->toBe(['user', 'group', 'servicePrincipal', 'unknown']); });