array_merge([ 'enabled' => 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, 'max_asset_bytes' => 512, 'max_output_bytes' => 2048, 'correlation_header' => 'Gotenberg-Trace', 'output_filename' => 'tenantpilot-management-report', ], $overrides), ]); } it('keeps the renderer disabled without issuing outbound HTTP', function (): void { configureSpec378PdfRenderer(['enabled' => false]); Http::fake(); $result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', correlationId: 'spec378-disabled', )); expect($result->failed())->toBeTrue() ->and($result->failureCode)->toBe(PdfRenderResult::RENDERER_DISABLED) ->and($result->safeMessage)->toBe('PDF renderer is disabled.') ->and($result->correlationId)->toBe('spec378-disabled'); Http::assertNothingSent(); }); it('checks Gotenberg health with the configured correlation header', function (): void { configureSpec378PdfRenderer(); Http::fake([ 'gotenberg.test/health' => Http::response('OK', 200, [ 'Content-Type' => 'text/plain', 'Gotenberg-Trace' => 'spec378-health', ]), ]); $result = app(PdfRenderingGateway::class)->healthCheck('spec378-health'); expect($result->successful())->toBeTrue() ->and($result->correlationId)->toBe('spec378-health'); Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' && $request->url() === 'http://gotenberg.test/health' && (($request->headers()['Gotenberg-Trace'][0] ?? null) === 'spec378-health')); }); it('keeps the Sail Gotenberg service pinned and internal only', function (): void { $compose = file_get_contents(repo_path('docker-compose.yml')); $compose = is_string($compose) ? $compose : ''; $gotenbergBlock = Str::between($compose, " gotenberg:\n", "\n pgsql:"); expect($gotenbergBlock)->toContain("image: 'gotenberg/gotenberg:8.34.0-chromium'") ->and($gotenbergBlock)->toContain("API_DISABLE_DOWNLOAD_FROM: 'true'") ->and($gotenbergBlock)->toContain("WEBHOOK_DISABLE: 'true'") ->and($gotenbergBlock)->toContain("CHROMIUM_ALLOW_LIST: '^$'") ->and($gotenbergBlock)->toContain("CHROMIUM_DENY_PRIVATE_IPS: 'true'") ->and($gotenbergBlock)->toContain("CHROMIUM_DENY_PUBLIC_IPS: 'true'") ->and($gotenbergBlock)->not->toContain("\n ports:"); }); it('rejects oversized HTML and unsafe asset names before the renderer call', function (): void { configureSpec378PdfRenderer([ 'max_html_bytes' => 8, ]); Http::fake(); $tooLarge = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'too large', correlationId: 'spec378-size', )); expect($tooLarge->failed())->toBeTrue() ->and($tooLarge->failureCode)->toBe(PdfRenderResult::PAYLOAD_TOO_LARGE); configureSpec378PdfRenderer(); $invalidAsset = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', assets: ['assets/logo.png' => 'image-bytes'], correlationId: 'spec378-asset', )); expect($invalidAsset->failed())->toBeTrue() ->and($invalidAsset->failureCode)->toBe(PdfRenderResult::INVALID_ASSET); Http::assertNothingSent(); }); it('renders server generated HTML through the configured Gotenberg route', function (): void { configureSpec378PdfRenderer(); Http::fake([ 'gotenberg.test/forms/chromium/convert/html' => Http::response('%PDF-1.7 rendered', 200, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename=management-report.pdf', 'Gotenberg-Trace' => 'spec378-render', ]), ]); $result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', assets: ['logo.png' => 'image-bytes'], options: [ 'printBackground' => true, 'paperWidth' => '8.27', ], correlationId: 'spec378-render', )); expect($result->successful())->toBeTrue() ->and($result->pdfBytes)->toBe('%PDF-1.7 rendered') ->and($result->contentType)->toBe('application/pdf') ->and($result->outputFilename)->toBe('management-report.pdf') ->and($result->correlationId)->toBe('spec378-render'); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://gotenberg.test/forms/chromium/convert/html' && (($request->headers()['Gotenberg-Trace'][0] ?? null) === 'spec378-render') && (($request->headers()['Gotenberg-Output-Filename'][0] ?? null) === 'tenantpilot-management-report')); }); it('maps renderer unavailable failures to safe results', function (): void { configureSpec378PdfRenderer([ 'base_url' => 'http://gotenberg-unavailable.test', ]); Http::fake([ 'gotenberg-unavailable.test/forms/chromium/convert/html' => Http::failedConnection(), ]); $unavailable = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', correlationId: 'spec378-unavailable', )); expect($unavailable->failed())->toBeTrue() ->and($unavailable->failureCode)->toBe(PdfRenderResult::RENDERER_UNAVAILABLE) ->and($unavailable->safeMessage)->not->toContain('SQLSTATE', 'access token', 'client secret', 'stack trace'); }); it('maps invalid renderer responses to safe results', function (): void { configureSpec378PdfRenderer([ 'base_url' => 'http://gotenberg-invalid.test', ]); Http::fake([ 'gotenberg-invalid.test/forms/chromium/convert/html' => Http::response('not a pdf', 200, [ 'Content-Type' => 'text/plain', 'Gotenberg-Trace' => 'spec378-invalid', ]), ]); $invalid = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', correlationId: 'spec378-invalid', )); expect($invalid->failed())->toBeTrue() ->and($invalid->failureCode)->toBe(PdfRenderResult::INVALID_RESPONSE) ->and($invalid->correlationId)->toBe('spec378-invalid'); }); it('maps renderer HTTP failures and output limits to safe result codes', function (int $status, string $expectedCode): void { configureSpec378PdfRenderer(); Http::fake([ 'gotenberg.test/forms/chromium/convert/html' => Http::response('SQLSTATE raw renderer body', $status, [ 'Content-Type' => 'text/plain', 'Gotenberg-Trace' => 'spec378-failure', ]), ]); $result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', correlationId: 'spec378-failure', )); expect($result->failed())->toBeTrue() ->and($result->failureCode)->toBe($expectedCode) ->and($result->safeMessage)->not->toContain('SQLSTATE'); })->with([ 'invalid request' => [400, PdfRenderResult::INVALID_REQUEST], 'timeout' => [503, PdfRenderResult::RENDERER_TIMEOUT], 'unexpected renderer failure' => [500, PdfRenderResult::RENDERER_FAILED], ]); it('rejects oversized PDF output after a successful renderer response', function (): void { configureSpec378PdfRenderer([ 'max_output_bytes' => 10, ]); Http::fake([ 'gotenberg.test/forms/chromium/convert/html' => Http::response('%PDF-1.7 document body', 200, [ 'Content-Type' => 'application/pdf', 'Gotenberg-Trace' => 'spec378-output', ]), ]); $result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest( html: 'Management report', correlationId: 'spec378-output', )); expect($result->failed())->toBeTrue() ->and($result->failureCode)->toBe(PdfRenderResult::OUTPUT_TOO_LARGE) ->and($result->correlationId)->toBe('spec378-output'); });