TenantAtlas/tests/Feature/ReviewPack/ReviewPackGenerationTest.php
ahmido 9f5c99317b Fix Review Pack generation UX + notifications (#133)
## Summary
- Fixes misleading “queued / running in background” message when Review Pack generation request reuses an existing ready pack (fingerprint dedupe).
- Improves resilience of Filament/Livewire interactions by ensuring the Livewire intercept shim applies after Livewire initializes.
- Aligns Review Pack operation notifications with Ops-UX patterns (queued + completed notifications) and removes the old ReviewPackStatusNotification.

## Key Changes
- Review Pack generate action now:
  - Shows queued toast only when a new pack is actually created/queued.
  - Shows a “Review pack already available” success notification with a link when dedupe returns an existing pack.

## Tests
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackResourceTest.php`
- `vendor/bin/sail artisan test --compact tests/Feature/LivewireInterceptShimTest.php`

## Notes
- No global search behavior changes for ReviewPacks (still excluded).
- Destructive actions remain confirmation-gated (`->requiresConfirmation()`).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #133
2026-02-23 19:42:52 +00:00

429 lines
15 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\OperationRunCompleted;
use App\Notifications\OperationRunQueued;
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,
);
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);
});