- ReviewPackService: generate, fingerprint dedupe, signed download URL - GenerateReviewPackJob: 12-step pipeline, ZIP assembly, failure handling - ReviewPackDownloadController: signed URL streaming with SHA-256 header - ReviewPackResource: list/view pages, generate/expire/download actions - TenantReviewPackCard: dashboard widget with 5 display states - ReviewPackPolicy: RBAC via REVIEW_PACK_VIEW/MANAGE capabilities - PruneReviewPacksCommand: retention automation + hard-delete option - ReviewPackStatusNotification: database channel, ready/failed payloads - Schedule: daily prune + entra admin roles, posture:dispatch deferred - AlertRuleResource: hide sla_due from dropdown (backward compat kept) - 59 passing tests across 7 test files (1 skipped: posture deferred) - All 36 tasks completed per tasks.md
416 lines
14 KiB
PHP
416 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\GenerateReviewPackJob;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Models\Tenant;
|
|
use App\Notifications\ReviewPackStatusNotification;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use App\Support\ReviewPackStatus;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
Storage::fake('exports');
|
|
});
|
|
|
|
// ─── Helper ──────────────────────────────────────────────────
|
|
|
|
function seedTenantWithData(Tenant $tenant): void
|
|
{
|
|
StoredReport::factory()->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,
|
|
);
|
|
$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
|
|
Notification::assertSentTo($user, ReviewPackStatusNotification::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 {
|
|
$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->context['reason_code'])->toBe('generation_error');
|
|
|
|
Notification::assertSentTo($user, ReviewPackStatusNotification::class, function ($notification) {
|
|
return $notification->status === 'failed';
|
|
});
|
|
});
|
|
|
|
// ─── 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,
|
|
);
|
|
$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,
|
|
);
|
|
$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,
|
|
);
|
|
$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();
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|