create([ 'tenant_id' => (int) $tenant->getKey(), 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, 'payload' => [ 'posture_score' => 86, 'required_count' => 14, 'granted_count' => 12, 'permissions' => [ ['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'], ], ], ]); StoredReport::factory()->create([ 'tenant_id' => (int) $tenant->getKey(), 'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES, 'payload' => [ 'roles' => [ [ 'displayName' => 'Global Administrator', 'userPrincipalName' => 'admin@contoso.com', 'role_template_id' => '62e90394-69f5-4237-9190-012177145e10', ], ], ], ]); Finding::factory() ->count(3) ->create(['tenant_id' => (int) $tenant->getKey()]); } // ─── Happy Path ────────────────────────────────────────────── it('generates a review pack end-to-end (happy path)', function (): void { [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); Notification::fake(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user, [ 'include_pii' => true, 'include_operations' => true, ]); expect($pack)->toBeInstanceOf(ReviewPack::class); expect($pack->status)->toBe(ReviewPackStatus::Queued->value); // Dispatch the queued job synchronously $job = new GenerateReviewPackJob( reviewPackId: (int) $pack->getKey(), operationRunId: (int) $pack->operation_run_id, ); app()->call([$job, 'handle']); $pack->refresh(); expect($pack->status)->toBe(ReviewPackStatus::Ready->value); expect($pack->sha256)->toBeString()->not->toBeEmpty(); expect($pack->file_size)->toBeGreaterThan(0); expect($pack->file_path)->toBeString()->not->toBeEmpty(); expect($pack->file_disk)->toBe('exports'); expect($pack->generated_at)->not->toBeNull(); expect($pack->expires_at)->not->toBeNull(); expect($pack->fingerprint)->toBeString()->not->toBeEmpty(); expect($pack->summary)->toBeArray(); expect($pack->summary['finding_count'])->toBe(3); expect($pack->summary['report_count'])->toBe(2); // File exists on disk Storage::disk('exports')->assertExists($pack->file_path); // OperationRun completed $opRun = OperationRun::query()->find($pack->operation_run_id); expect($opRun->status)->toBe(OperationRunStatus::Completed->value); expect($opRun->outcome)->toBe(OperationRunOutcome::Succeeded->value); // Notification sent (standard OperationRunCompleted via OperationRunService) Notification::assertSentTo($user, OperationRunCompleted::class); }); // ─── Failure Path ────────────────────────────────────────────── it('marks pack as failed when generation throws an exception', function (): void { [$user, $tenant] = createUserWithTenant(); Notification::fake(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user); // Replace the exports disk with a mock that throws on put() $fakeDisk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); $fakeDisk->shouldReceive('put') ->andThrow(new \RuntimeException('Simulated storage failure')); Storage::shouldReceive('disk') ->with('exports') ->andReturn($fakeDisk); $job = new GenerateReviewPackJob( reviewPackId: (int) $pack->getKey(), operationRunId: (int) $pack->operation_run_id, ); try { app()->call([$job, 'handle']); } catch (\RuntimeException) { // Expected — the job re-throws after marking failed } $pack->refresh(); expect($pack->status)->toBe(ReviewPackStatus::Failed->value); $opRun = OperationRun::query()->find($pack->operation_run_id); expect($opRun->status)->toBe(OperationRunStatus::Completed->value); expect($opRun->outcome)->toBe(OperationRunOutcome::Failed->value); expect($opRun->failure_summary)->toBeArray(); expect($opRun->failure_summary[0]['code'])->toBe('generation_error'); Notification::assertSentTo($user, OperationRunCompleted::class); }); // ─── Empty Reports ────────────────────────────────────────────── it('succeeds with empty reports and findings', function (): void { [$user, $tenant] = createUserWithTenant(); Notification::fake(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user); $job = new GenerateReviewPackJob( reviewPackId: (int) $pack->getKey(), operationRunId: (int) $pack->operation_run_id, ); app()->call([$job, 'handle']); $pack->refresh(); expect($pack->status)->toBe(ReviewPackStatus::Ready->value); expect($pack->summary['finding_count'])->toBe(0); expect($pack->summary['report_count'])->toBe(0); Storage::disk('exports')->assertExists($pack->file_path); }); // ─── PII Redaction ────────────────────────────────────────────── it('redacts PII when include_pii is false', function (): void { [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); Notification::fake(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user, ['include_pii' => false]); $job = new GenerateReviewPackJob( reviewPackId: (int) $pack->getKey(), operationRunId: (int) $pack->operation_run_id, ); app()->call([$job, 'handle']); $pack->refresh(); expect($pack->status)->toBe(ReviewPackStatus::Ready->value); // Read the generated ZIP to verify PII redaction $zipContent = Storage::disk('exports')->get($pack->file_path); $tempFile = tempnam(sys_get_temp_dir(), 'test-zip-'); file_put_contents($tempFile, $zipContent); $zip = new ZipArchive; $zip->open($tempFile); // Check metadata.json redacts tenant name $metadata = json_decode($zip->getFromName('metadata.json'), true); expect($metadata['tenant_name'])->toBe('[REDACTED]'); expect($metadata['options']['include_pii'])->toBeFalse(); // Check findings.csv redacts title in rows $findingsCsv = $zip->getFromName('findings.csv'); expect($findingsCsv)->toContain('[REDACTED]'); // Check entra_admin_roles.json redacts displayName $entraReport = json_decode($zip->getFromName('reports/entra_admin_roles.json'), true); if (! empty($entraReport) && isset($entraReport['roles'])) { foreach ($entraReport['roles'] as $role) { if (isset($role['displayName'])) { expect($role['displayName'])->toBe('[REDACTED]'); } } } $zip->close(); unlink($tempFile); }); // ─── ZIP Contents ────────────────────────────────────────────── it('produces a ZIP with exactly 7 files in alphabetical order', function (): void { [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); Notification::fake(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user, [ 'include_pii' => true, 'include_operations' => true, ]); $job = new GenerateReviewPackJob( reviewPackId: (int) $pack->getKey(), operationRunId: (int) $pack->operation_run_id, ); app()->call([$job, 'handle']); $pack->refresh(); $zipContent = Storage::disk('exports')->get($pack->file_path); $tempFile = tempnam(sys_get_temp_dir(), 'test-zip-'); file_put_contents($tempFile, $zipContent); $zip = new ZipArchive; $zip->open($tempFile); $files = []; for ($i = 0; $i < $zip->numFiles; $i++) { $files[] = $zip->getNameIndex($i); } $zip->close(); unlink($tempFile); $expectedFiles = [ 'findings.csv', 'hardening.json', 'metadata.json', 'operations.csv', 'reports/entra_admin_roles.json', 'reports/permission_posture.json', 'summary.json', ]; expect($files)->toHaveCount(7); expect($files)->toEqual($expectedFiles); }); // ─── Service dispatches job ────────────────────────────────── it('dispatches GenerateReviewPackJob when generate is called', function (): void { Queue::fake(); [$user, $tenant] = createUserWithTenant(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user); Queue::assertPushed(GenerateReviewPackJob::class, function ($job) use ($pack) { return $job->reviewPackId === (int) $pack->getKey(); }); }); it('sends queued database notification when review pack generation is requested', function (): void { Queue::fake(); Notification::fake(); [$user, $tenant] = createUserWithTenant(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $service->generate($tenant, $user); Notification::assertSentTo($user, OperationRunQueued::class); }); // ─── OperationRun Type ────────────────────────────────────────── it('creates an OperationRun of type review_pack_generate', function (): void { Queue::fake(); [$user, $tenant] = createUserWithTenant(); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $pack = $service->generate($tenant, $user); $opRun = OperationRun::query()->find($pack->operation_run_id); expect($opRun)->not->toBeNull(); expect($opRun->type)->toBe(OperationRunType::ReviewPackGenerate->value); expect($opRun->status)->toBe(OperationRunStatus::Queued->value); }); // ─── Fingerprint Determinism ────────────────────────────────── it('computes the same fingerprint for identical inputs', function (): void { [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $options = ['include_pii' => true, 'include_operations' => true]; $fp1 = $service->computeFingerprint($tenant, $options); $fp2 = $service->computeFingerprint($tenant, $options); expect($fp1)->toBe($fp2); expect(strlen($fp1))->toBe(64); // SHA-256 hex length }); // ─── Different options produce different fingerprints ───────── it('computes different fingerprints when options differ', function (): void { [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $fp1 = $service->computeFingerprint($tenant, ['include_pii' => true, 'include_operations' => true]); $fp2 = $service->computeFingerprint($tenant, ['include_pii' => false, 'include_operations' => true]); expect($fp1)->not->toBe($fp2); }); // ─── Fingerprint Dedupe (T025) ──────────────────────────────── it('returns existing ready pack when fingerprint matches (dedupe)', function (): void { Queue::fake(); [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $options = ['include_pii' => true, 'include_operations' => true]; // Compute the fingerprint that the service would compute with normalized options $fingerprint = $service->computeFingerprint($tenant, $options); $pack1 = $service->generate($tenant, $user, $options); // Manually set the pack to ready with the correct fingerprint so dedupe triggers $pack1->update([ 'status' => ReviewPackStatus::Ready->value, 'fingerprint' => $fingerprint, 'expires_at' => now()->addDays(90), ]); // Second call with same options should return the existing pack $pack2 = $service->generate($tenant, $user, $options); expect($pack2->getKey())->toBe($pack1->getKey()); expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1); }); it('allows new generation when existing pack with same fingerprint is expired', function (): void { Queue::fake(); [$user, $tenant] = createUserWithTenant(); seedTenantWithData($tenant); /** @var ReviewPackService $service */ $service = app(ReviewPackService::class); $options = ['include_pii' => true, 'include_operations' => true]; // Create an expired pack with a matching fingerprint $fingerprint = $service->computeFingerprint($tenant, ['include_pii' => true, 'include_operations' => true]); ReviewPack::factory()->expired()->create([ 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'initiated_by_user_id' => (int) $user->getKey(), 'fingerprint' => $fingerprint, ]); // Should create a new pack since existing is expired $newPack = $service->generate($tenant, $user, $options); expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(2); expect($newPack->status)->toBe(ReviewPackStatus::Queued->value); });