TenantAtlas/apps/platform/tests/Feature/ReviewPack/Spec404ManagementReportPdfRuntimeValidationTest.php
Ahmed Darrazi a0f376ae7d
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m7s
feat: finish management report PDF staging validation
2026-06-23 20:22:51 +02:00

284 lines
10 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\GenerateManagementReportPdfJob;
use App\Models\AuditLog;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\ReviewPacks\ManagementReportPdfService;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ReviewPacks\ReportProfileRegistry;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\Support\FailHardGraphClient;
beforeEach(function (): void {
Storage::fake('exports');
Storage::fake('public');
app()->instance(GraphClientInterface::class, new FailHardGraphClient);
});
function spec404ConfigurePdfRenderer(): void
{
config([
'tenantpilot.pdf_renderer' => [
'enabled' => true,
'runtime_validated' => true,
'driver' => 'gotenberg',
'base_url' => 'http://gotenberg.test',
'health_path' => '/health',
'html_route' => '/forms/chromium/convert/html',
'timeout_seconds' => 30,
'connect_timeout_seconds' => 5,
'max_html_bytes' => 1024 * 1024,
'max_asset_bytes' => 512 * 1024,
'max_output_bytes' => 2 * 1024 * 1024,
'correlation_header' => 'Gotenberg-Trace',
'output_filename' => 'tenantpilot-management-report',
],
]);
}
/**
* @return array{0: User, 1: ManagedEnvironment, 2: ReviewPack}
*/
function spec404CurrentReadyPack(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner', clearCapabilityCaches: true);
$snapshot = seedEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeEnvironmentReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => 'published',
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$review = markEnvironmentReviewCustomerSafeReady($review);
$zipContents = spec404ZipContents();
$filePath = sprintf('review-packs/%s/spec404-current.zip', $tenant->external_id);
Storage::disk('exports')->put($filePath, $zipContents);
$pack = ReviewPack::factory()->ready()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'environment_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'options' => [
'include_pii' => false,
'include_operations' => true,
],
'summary' => [
'governance_package' => [
'executive_summary' => 'Spec404 validates the management report PDF runtime path.',
],
'control_interpretation' => [
'non_certification_disclosure' => 'TenantPilot summarizes available evidence and does not certify compliance.',
],
],
'file_path' => $filePath,
'file_disk' => 'exports',
'file_size' => strlen($zipContents),
'sha256' => hash('sha256', $zipContents),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
return [$user, $tenant, $pack->fresh(['tenant', 'environmentReview'])];
}
function spec404ZipContents(): string
{
$tempFile = tempnam(sys_get_temp_dir(), 'spec404-review-pack-');
if ($tempFile === false) {
throw new RuntimeException('Failed to allocate Spec404 archive.');
}
try {
$zip = new ZipArchive;
$result = $zip->open($tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
throw new RuntimeException("Failed to create Spec404 archive: {$result}");
}
$zip->addFromString('metadata.json', json_encode(['fixture' => 'spec404'], JSON_THROW_ON_ERROR));
$zip->addFromString('executive-summary.md', 'Spec404 current review pack.');
$zip->close();
$contents = file_get_contents($tempFile);
if (! is_string($contents) || $contents === '') {
throw new RuntimeException('Spec404 archive is empty.');
}
return $contents;
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
function spec404PdfBytes(string $label): string
{
return "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Title ({$label}) >>\nendobj\nstartxref\n0\n%%EOF";
}
function spec404TruncatedPdfBytes(): string
{
return "%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n";
}
function spec404ManagementPdfWithStoredBytes(
ReviewPack $pack,
string $bytes,
?string $sha256 = null,
?int $fileSize = null,
string $fileDisk = 'exports',
): StoredReport
{
$filePath = sprintf('management-reports/%s/spec404-%d.pdf', $pack->tenant->external_id, (int) $pack->getKey());
Storage::disk($fileDisk)->put($filePath, $bytes);
return StoredReport::factory()->managementReportPdf([
'title' => 'Spec404 Management Report',
])->create([
'workspace_id' => (int) $pack->workspace_id,
'managed_environment_id' => (int) $pack->managed_environment_id,
'source_environment_review_id' => (int) $pack->environment_review_id,
'source_review_pack_id' => (int) $pack->getKey(),
'profile' => ReportProfileRegistry::CUSTOMER_EXECUTIVE,
'file_disk' => $fileDisk,
'file_path' => $filePath,
'file_size' => $fileSize ?? max(1, strlen($bytes)),
'sha256' => $sha256 ?? hash('sha256', $bytes),
'generated_at' => now(),
]);
}
it('Spec404 rejects renderer success responses that are not valid PDF bytes', function (string $rendererBytes): void {
Queue::fake();
spec404ConfigurePdfRenderer();
[$user, , $pack] = spec404CurrentReadyPack();
$result = app(ManagementReportPdfService::class)->startGeneration($pack, $user);
/** @var StoredReport $report */
$report = $result['report'];
/** @var OperationRun $run */
$run = $result['operation_run'];
Http::fake([
'gotenberg.test/forms/chromium/convert/html' => Http::response($rendererBytes, 200, [
'Content-Type' => 'application/pdf',
'Gotenberg-Trace' => 'spec404-corrupt-render',
]),
]);
app()->call([new GenerateManagementReportPdfJob(
storedReportId: (int) $report->getKey(),
operationRunId: (int) $run->getKey(),
), 'handle']);
$report->refresh();
$run->refresh();
expect($report->status)->toBe(StoredReport::STATUS_FAILED)
->and($report->file_path)->toBeNull()
->and($report->sha256)->toBeNull()
->and($run->status)->toBe(OperationRunStatus::Completed->value)
->and($run->outcome)->toBe(OperationRunOutcome::Failed->value)
->and(data_get($run->failure_summary, '0.code'))->toBe('management_report_pdf.invalid_response');
Http::assertSent(fn (Request $request): bool => $request->url() === 'http://gotenberg.test/forms/chromium/convert/html');
})->with([
'non-pdf bytes' => ['not a pdf'],
'truncated pdf bytes' => [spec404TruncatedPdfBytes()],
]);
it('Spec404 refuses signed management PDF downloads when stored bytes are invalid', function (string $bytes, ?string $sha256, ?int $fileSize = null): void {
[$user, , $pack] = spec404CurrentReadyPack();
$report = spec404ManagementPdfWithStoredBytes($pack, $bytes, $sha256, $fileSize);
$url = app(ManagementReportPdfService::class)->generateDownloadUrl($report, [
'source_surface' => 'spec404',
]);
$this->actingAs($user)
->get($url)
->assertNotFound();
expect(AuditLog::query()
->where('action', AuditActionId::ManagementReportPdfDownloaded->value)
->count())->toBe(0);
})->with([
'zero-byte file' => ['', hash('sha256', '')],
'non-pdf file' => ['plain text report', hash('sha256', 'plain text report')],
'truncated pdf file' => [spec404TruncatedPdfBytes(), hash('sha256', spec404TruncatedPdfBytes())],
'sha mismatch' => [spec404PdfBytes('Spec404 valid-looking report'), str_repeat('0', 64)],
'file-size mismatch' => [spec404PdfBytes('Spec404 valid-looking report'), hash('sha256', spec404PdfBytes('Spec404 valid-looking report')), 999],
]);
it('Spec404 refuses ready management PDF downloads stored outside the private exports disk', function (): void {
[$user, , $pack] = spec404CurrentReadyPack();
$pdfBytes = spec404PdfBytes('Spec404 wrong disk report');
$report = spec404ManagementPdfWithStoredBytes(
pack: $pack,
bytes: $pdfBytes,
sha256: hash('sha256', $pdfBytes),
fileSize: strlen($pdfBytes),
fileDisk: 'public',
);
$url = app(ManagementReportPdfService::class)->generateDownloadUrl($report, [
'source_surface' => 'spec404',
]);
expect($report->isReadyManagementPdf())->toBeFalse()
->and(app(ManagementReportPdfService::class)->findReadyReport($pack))->toBeNull();
$this->actingAs($user)
->get($url)
->assertNotFound();
expect(AuditLog::query()
->where('action', AuditActionId::ManagementReportPdfDownloaded->value)
->count())->toBe(0);
});
it('Spec404 does not treat ready metadata as downloadable when the private file is missing', function (): void {
[$user, , $pack] = spec404CurrentReadyPack();
$pdfBytes = spec404PdfBytes('Spec404 missing private file');
$report = spec404ManagementPdfWithStoredBytes($pack, $pdfBytes);
Storage::disk('exports')->delete((string) $report->file_path);
$service = app(ManagementReportPdfService::class);
$url = $service->generateDownloadUrl($report, [
'source_surface' => 'spec404',
]);
expect($report->isReadyManagementPdf())->toBeTrue()
->and($service->findReadyReport($pack))->toBeNull()
->and($service->generationDecision($pack)['state'])->not->toBe('ready');
$this->actingAs($user)
->get($url)
->assertNotFound();
expect(AuditLog::query()
->where('action', AuditActionId::ManagementReportPdfDownloaded->value)
->count())->toBe(0);
});