'def-ga', 'displayName' => 'Global Administrator', 'templateId' => '62e90394-69f5-4237-9190-012177145e10', 'isBuiltIn' => true, ], ]; } function scanJobAssignments(): array { return [ [ 'id' => 'assign-1', 'roleDefinitionId' => 'def-ga', 'principalId' => 'user-aaa', 'directoryScopeId' => '/', 'principal' => [ '@odata.type' => '#microsoft.graph.user', 'displayName' => 'Alice Admin', ], ], ]; } function scanJobGraphMock(bool $failDefinitions = false, bool $failAssignments = false): GraphClientInterface { return new class(scanJobRoleDefs(), scanJobAssignments(), $failDefinitions, $failAssignments) implements GraphClientInterface { public function __construct( private readonly array $roleDefs, private readonly array $assignments, private readonly bool $failDefs, private readonly bool $failAssigns, ) {} public function listPolicies(string $policyType, array $options = []): GraphResponse { return match ($policyType) { 'entraRoleDefinitions' => new GraphResponse( success: ! $this->failDefs, data: $this->failDefs ? [] : $this->roleDefs, status: $this->failDefs ? 403 : 200, errors: $this->failDefs ? ['Forbidden'] : [], ), 'entraRoleAssignments' => new GraphResponse( success: ! $this->failAssigns, data: $this->failAssigns ? [] : $this->assignments, status: $this->failAssigns ? 403 : 200, errors: $this->failAssigns ? ['Forbidden'] : [], ), default => new GraphResponse(success: false, status: 404), }; } 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); } }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- it('successful run creates OperationRun and records success with counts', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $mock = scanJobGraphMock(); $job = new ScanEntraAdminRolesJob( tenantId: (int) $tenant->getKey(), workspaceId: (int) $tenant->workspace_id, ); $job->handle( buildScanReportService($mock), new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog), app(\App\Services\OperationRunService::class), ); $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'entra.admin_roles.scan') ->first(); expect($run)->not->toBeNull() ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) ->and($run->summary_counts)->toBeArray() ->and($run->summary_counts['report_created'])->toBe(1) ->and($run->summary_counts['findings_created'])->toBeGreaterThanOrEqual(1); // Verify report and findings also exist $report = StoredReport::query() ->where('tenant_id', $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->first(); expect($report)->not->toBeNull(); $findingsCount = Finding::query() ->where('tenant_id', $tenant->getKey()) ->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) ->count(); expect($findingsCount)->toBeGreaterThanOrEqual(1); }); it('skips tenant without active provider connection', function (): void { [$user, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false); // Deliberately NOT creating a provider connection $job = new ScanEntraAdminRolesJob( tenantId: (int) $tenant->getKey(), workspaceId: (int) $tenant->workspace_id, ); $job->handle( buildScanReportService(scanJobGraphMock()), new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog), app(\App\Services\OperationRunService::class), ); $runCount = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'entra.admin_roles.scan') ->count(); expect($runCount)->toBe(0); $reportCount = StoredReport::query() ->where('tenant_id', $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->count(); expect($reportCount)->toBe(0); }); it('Graph failure marks OperationRun as failed and re-throws', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $mock = scanJobGraphMock(failDefinitions: true); $job = new ScanEntraAdminRolesJob( tenantId: (int) $tenant->getKey(), workspaceId: (int) $tenant->workspace_id, ); $thrown = false; try { $job->handle( buildScanReportService($mock), new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog), app(\App\Services\OperationRunService::class), ); } catch (RuntimeException) { $thrown = true; } expect($thrown)->toBeTrue(); $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'entra.admin_roles.scan') ->first(); expect($run)->not->toBeNull() ->and($run->status)->toBe(OperationRunStatus::Completed->value) ->and($run->outcome)->toBe(OperationRunOutcome::Failed->value) ->and($run->failure_summary)->toBeArray() ->and($run->failure_summary[0]['code'])->toBe('entra.admin_roles.scan.failed'); }); it('finding generator runs even when report is deduped', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); $mock = scanJobGraphMock(); $reportService = buildScanReportService($mock); $findingGenerator = new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog); $runService = app(\App\Services\OperationRunService::class); // Run 1: creates report $job1 = new ScanEntraAdminRolesJob( tenantId: (int) $tenant->getKey(), workspaceId: (int) $tenant->workspace_id, ); $job1->handle($reportService, $findingGenerator, $runService); $reportCount = StoredReport::query() ->where('tenant_id', $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->count(); expect($reportCount)->toBe(1); // Mark existing OperationRun as completed so a new one can be created OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'entra.admin_roles.scan') ->update(['status' => OperationRunStatus::Completed->value]); // Run 2: report deduped, but findings still processed $job2 = new ScanEntraAdminRolesJob( tenantId: (int) $tenant->getKey(), workspaceId: (int) $tenant->workspace_id, ); $job2->handle($reportService, $findingGenerator, $runService); // Still only 1 report (deduped) $reportCount2 = StoredReport::query() ->where('tenant_id', $tenant->getKey()) ->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES) ->count(); expect($reportCount2)->toBe(1); // But the run completed successfully with deduped count $latestRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'entra.admin_roles.scan') ->where('outcome', OperationRunOutcome::Succeeded->value) ->latest() ->first(); expect($latestRun)->not->toBeNull() ->and($latestRun->summary_counts['report_deduped'])->toBe(1); });