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); });