Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m59s
Added PDF generation service for management reports as per Spec 378, including Gotenberg integration in docker-compose and configuration updates.
232 lines
8.7 KiB
PHP
232 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Services\Pdf\PdfRenderRequest;
|
|
use App\Services\Pdf\PdfRenderResult;
|
|
use App\Services\Pdf\PdfRenderingGateway;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
function configureSpec378PdfRenderer(array $overrides = []): void
|
|
{
|
|
config([
|
|
'tenantpilot.pdf_renderer' => 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: '<html><body>Management report</body></html>',
|
|
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: '<html>too large</html>',
|
|
correlationId: 'spec378-size',
|
|
));
|
|
|
|
expect($tooLarge->failed())->toBeTrue()
|
|
->and($tooLarge->failureCode)->toBe(PdfRenderResult::PAYLOAD_TOO_LARGE);
|
|
|
|
configureSpec378PdfRenderer();
|
|
|
|
$invalidAsset = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
|
|
html: '<html><body>Management report</body></html>',
|
|
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: '<html><body>Management report</body></html>',
|
|
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: '<html><body>Management report</body></html>',
|
|
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: '<html><body>Management report</body></html>',
|
|
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: '<html><body>Management report</body></html>',
|
|
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: '<html><body>Management report</body></html>',
|
|
correlationId: 'spec378-output',
|
|
));
|
|
|
|
expect($result->failed())->toBeTrue()
|
|
->and($result->failureCode)->toBe(PdfRenderResult::OUTPUT_TOO_LARGE)
|
|
->and($result->correlationId)->toBe('spec378-output');
|
|
});
|