## Summary - add the Evidence Snapshot domain with immutable tenant-scoped snapshots, per-dimension items, queued generation, audit actions, badge mappings, and Filament list/detail surfaces - add the workspace evidence overview, capability and policy wiring, Livewire update-path hardening, and review-pack integration through explicit evidence snapshot resolution - add spec 153 artifacts, migrations, factories, and focused Pest coverage for evidence, review-pack reuse, authorization, action-surface regressions, and audit behavior ## Testing - `vendor/bin/sail artisan test --compact --stop-on-failure` - `CI=1 vendor/bin/sail artisan test --compact` - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - branch: `153-evidence-domain-foundation` - commit: `b7dfa279` - spec: `specs/153-evidence-domain-foundation/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #183
595 lines
22 KiB
PHP
595 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
|
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
|
use App\Jobs\GenerateReviewPackJob;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ReviewPack;
|
|
use App\Models\StoredReport;
|
|
use App\Models\Tenant;
|
|
use App\Notifications\OperationRunCompleted;
|
|
use App\Notifications\OperationRunQueued;
|
|
use App\Services\Evidence\EvidenceSnapshotService;
|
|
use App\Services\ReviewPackService;
|
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
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');
|
|
});
|
|
|
|
it('treats only queued and generating review packs as active for card polling', function (string $status, ?string $expectedInterval): void {
|
|
$tenant = Tenant::factory()->create();
|
|
|
|
$pack = match ($status) {
|
|
ReviewPackStatus::Queued->value => ReviewPack::factory()->queued()->make([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]),
|
|
ReviewPackStatus::Generating->value => ReviewPack::factory()->generating()->make([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]),
|
|
ReviewPackStatus::Ready->value => ReviewPack::factory()->ready()->make([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]),
|
|
ReviewPackStatus::Failed->value => ReviewPack::factory()->failed()->make([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]),
|
|
ReviewPackStatus::Expired->value => ReviewPack::factory()->expired()->make([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
]),
|
|
default => ReviewPack::factory()->make([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => $status,
|
|
]),
|
|
};
|
|
|
|
expect(TenantReviewPackCard::resolvePollingInterval($pack))->toBe($expectedInterval);
|
|
})->with([
|
|
'queued pack' => [ReviewPackStatus::Queued->value, '10s'],
|
|
'generating pack' => [ReviewPackStatus::Generating->value, '10s'],
|
|
'ready pack' => [ReviewPackStatus::Ready->value, null],
|
|
'failed pack' => [ReviewPackStatus::Failed->value, null],
|
|
'expired pack' => [ReviewPackStatus::Expired->value, null],
|
|
'unknown status' => ['stalled', null],
|
|
]);
|
|
|
|
it('does not poll the review pack card when no pack exists', function (): void {
|
|
expect(TenantReviewPackCard::resolvePollingInterval(null))->toBeNull();
|
|
});
|
|
|
|
// ─── 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()]);
|
|
|
|
Finding::factory()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
]);
|
|
|
|
OperationRun::factory()->forTenant($tenant)->create();
|
|
}
|
|
|
|
function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
|
|
{
|
|
/** @var EvidenceSnapshotService $service */
|
|
$service = app(EvidenceSnapshotService::class);
|
|
$payload = $service->buildSnapshotPayload($tenant);
|
|
|
|
$snapshot = EvidenceSnapshot::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'fingerprint' => $payload['fingerprint'],
|
|
'completeness_state' => $payload['completeness'],
|
|
'summary' => $payload['summary'],
|
|
'generated_at' => now(),
|
|
]);
|
|
|
|
foreach ($payload['items'] as $item) {
|
|
$snapshot->items()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'dimension_key' => $item['dimension_key'],
|
|
'state' => $item['state'],
|
|
'required' => $item['required'],
|
|
'source_kind' => $item['source_kind'],
|
|
'source_record_type' => $item['source_record_type'],
|
|
'source_record_id' => $item['source_record_id'],
|
|
'source_fingerprint' => $item['source_fingerprint'],
|
|
'measured_at' => $item['measured_at'],
|
|
'freshness_at' => $item['freshness_at'],
|
|
'summary_payload' => $item['summary_payload'],
|
|
'sort_order' => $item['sort_order'],
|
|
]);
|
|
}
|
|
|
|
return $snapshot->load('items');
|
|
}
|
|
|
|
// ─── Happy Path ──────────────────────────────────────────────
|
|
|
|
it('generates a review pack end-to-end (happy path)', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
seedTenantWithData($tenant);
|
|
$snapshot = createEvidenceSnapshotForReviewPack($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(4);
|
|
expect($pack->summary['report_count'])->toBe(2);
|
|
expect($pack->evidence_snapshot_id)->toBe((int) $snapshot->getKey());
|
|
|
|
// 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();
|
|
|
|
seedTenantWithData($tenant);
|
|
createEvidenceSnapshotForReviewPack($tenant);
|
|
|
|
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('fails explicitly when no evidence snapshot exists', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
/** @var ReviewPackService $service */
|
|
$service = app(ReviewPackService::class);
|
|
|
|
expect(fn (): ReviewPack => $service->generate($tenant, $user))
|
|
->toThrow(ReviewPackEvidenceResolutionException::class, 'No eligible evidence snapshot is available');
|
|
});
|
|
|
|
it('fails explicitly when the latest evidence snapshot is ineligible', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
$snapshot = EvidenceSnapshot::query()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'status' => EvidenceSnapshotStatus::Active->value,
|
|
'completeness_state' => 'missing',
|
|
'summary' => [],
|
|
'generated_at' => now(),
|
|
]);
|
|
|
|
$snapshot->items()->create([
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'dimension_key' => 'findings_summary',
|
|
'state' => 'missing',
|
|
'required' => true,
|
|
'source_kind' => 'model_summary',
|
|
'source_record_type' => Finding::class,
|
|
'summary_payload' => [],
|
|
'sort_order' => 10,
|
|
]);
|
|
|
|
/** @var ReviewPackService $service */
|
|
$service = app(ReviewPackService::class);
|
|
|
|
expect(fn (): ReviewPack => $service->generate($tenant, $user))
|
|
->toThrow(ReviewPackEvidenceResolutionException::class, 'latest evidence snapshot is not eligible');
|
|
});
|
|
|
|
// ─── PII Redaction ──────────────────────────────────────────────
|
|
|
|
it('redacts PII when include_pii is false', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
seedTenantWithData($tenant);
|
|
createEvidenceSnapshotForReviewPack($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);
|
|
createEvidenceSnapshotForReviewPack($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();
|
|
seedTenantWithData($tenant);
|
|
createEvidenceSnapshotForReviewPack($tenant);
|
|
|
|
/** @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('does not send queued database notification when review pack generation is requested', function (): void {
|
|
Queue::fake();
|
|
Notification::fake();
|
|
|
|
[$user, $tenant] = createUserWithTenant();
|
|
seedTenantWithData($tenant);
|
|
createEvidenceSnapshotForReviewPack($tenant);
|
|
|
|
/** @var ReviewPackService $service */
|
|
$service = app(ReviewPackService::class);
|
|
$service->generate($tenant, $user);
|
|
|
|
Notification::assertNotSentTo($user, OperationRunQueued::class);
|
|
});
|
|
|
|
// ─── OperationRun Type ──────────────────────────────────────────
|
|
|
|
it('creates an OperationRun of type review_pack_generate', function (): void {
|
|
Queue::fake();
|
|
|
|
[$user, $tenant] = createUserWithTenant();
|
|
seedTenantWithData($tenant);
|
|
createEvidenceSnapshotForReviewPack($tenant);
|
|
|
|
/** @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);
|
|
createEvidenceSnapshotForReviewPack($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);
|
|
createEvidenceSnapshotForReviewPack($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);
|
|
$snapshot = createEvidenceSnapshotForReviewPack($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,
|
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
'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);
|
|
$snapshot = createEvidenceSnapshotForReviewPack($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(),
|
|
'evidence_snapshot_id' => (int) $snapshot->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);
|
|
});
|
|
|
|
it('builds the review pack from snapshot payloads instead of live records', function (): void {
|
|
[$user, $tenant] = createUserWithTenant();
|
|
|
|
seedTenantWithData($tenant);
|
|
$snapshot = createEvidenceSnapshotForReviewPack($tenant);
|
|
Notification::fake();
|
|
|
|
StoredReport::query()->where('tenant_id', (int) $tenant->getKey())->delete();
|
|
Finding::query()->where('tenant_id', (int) $tenant->getKey())->delete();
|
|
OperationRun::query()->where('tenant_id', (int) $tenant->getKey())->delete();
|
|
|
|
/** @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();
|
|
|
|
expect($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
|
->and($pack->evidence_snapshot_id)->toBe((int) $snapshot->getKey())
|
|
->and($pack->summary['finding_count'])->toBe((int) $snapshot->summary['finding_count'])
|
|
->and($pack->summary['report_count'])->toBe((int) $snapshot->summary['report_count']);
|
|
|
|
Storage::disk('exports')->assertExists($pack->file_path);
|
|
});
|