Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 5m7s
Added jobs, controllers, and PDF generation logic for management report runtime as defined in Spec 379. Includes artifact migrations, payload builders, and testing coverage.
233 lines
8.9 KiB
PHP
233 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Services\Pdf\PdfRenderingGateway;
|
|
use App\Services\Pdf\PdfRenderRequest;
|
|
use App\Services\Pdf\PdfRenderResult;
|
|
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_FILE_ACCESS_FROM_FILES: \'${GOTENBERG_CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES:-true}\'')
|
|
->and($gotenbergBlock)->toContain('CHROMIUM_ALLOW_LIST: \'${GOTENBERG_CHROMIUM_ALLOW_LIST:-^file:///tmp/.*$}\'')
|
|
->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');
|
|
});
|