From f58b079c182d8e7cf1bf9c4c1e59692ca3ed5b63 Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 5 Sep 2026 01:06:01 +0300 Subject: [PATCH] fix: bound social preview smoke response --- scripts/smoke-production.mjs | 57 +++++++++++++++++++++++++++++++++++- tests/deployment/seo.spec.ts | 49 +++++++++++++++++++++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-production.mjs b/scripts/smoke-production.mjs index 4138cdc..d051265 100644 --- a/scripts/smoke-production.mjs +++ b/scripts/smoke-production.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { pathToFileURL } from 'node:url'; +import { PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs'; const sha256Pattern = /^[a-f0-9]{64}$/u; const privateIdentifiers = /(?:simpastil|simpasteel|neon[ -]?debugger|yourhealth)/iu; @@ -144,6 +145,56 @@ async function request(fetchImpl, url, init = {}) { return response; } +async function cancelResponseBody(response) { + try { await response.body?.cancel(); } catch { /* Preserve the validation error that required cancellation. */ } +} + +async function countBoundedResponseBody(response, label, maxBytes) { + const contentLengthHeader = response.headers.get('content-length'); + let declaredBytes = null; + if (contentLengthHeader !== null) { + if (!/^(?:0|[1-9][0-9]*)$/u.test(contentLengthHeader)) { + await cancelResponseBody(response); + throw new Error(`${label} content-length is malformed.`); + } + declaredBytes = Number(contentLengthHeader); + if (!Number.isSafeInteger(declaredBytes)) { + await cancelResponseBody(response); + throw new Error(`${label} content-length is outside the safe integer range.`); + } + if (declaredBytes === 0) { + await cancelResponseBody(response); + throw new Error(`${label} response is empty.`); + } + if (declaredBytes > maxBytes) { + await cancelResponseBody(response); + throw new Error(`${label} exceeds the ${maxBytes}-byte project-still limit.`); + } + } + if (response.body === null) throw new Error(`${label} response body is missing.`); + + const reader = response.body.getReader(); + let receivedBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedBytes += value.byteLength; + if (receivedBytes > maxBytes) { + try { await reader.cancel(); } catch { /* Preserve the byte-limit validation error. */ } + throw new Error(`${label} exceeds the ${maxBytes}-byte project-still limit.`); + } + } + } finally { + reader.releaseLock(); + } + if (receivedBytes === 0) throw new Error(`${label} response is empty.`); + if (declaredBytes !== null && receivedBytes !== declaredBytes) { + throw new Error(`${label} content-length does not match the streamed byte count.`); + } + return receivedBytes; +} + export async function verifyProductionSmoke(options) { const canonical = rootHttpsUrl(options.canonicalUrl, 'Canonical URL'); if (!sha256Pattern.test(options.expectedManifestDigest)) throw new Error('Expected signed manifest digest must be lowercase SHA-256.'); @@ -182,7 +233,11 @@ export async function verifyProductionSmoke(options) { if (socialImageType === null || !new RegExp(`^${publicMetadata.socialImageType}(?:;|$)`, 'u').test(socialImageType)) { throw new Error('Production social image content-type is missing or unsupported.'); } - if ((await socialImageResponse.arrayBuffer()).byteLength === 0) throw new Error('Production social image response is empty.'); + await countBoundedResponseBody( + socialImageResponse, + 'Production social image', + PERFORMANCE_BUDGETS.projectStill, + ); for (const path of ['/robots.txt', '/sitemap.xml']) { const response = await request(fetchImpl, new URL(path, canonical)); if (response.status !== 200) throw new Error(`${path} did not return 200.`); diff --git a/tests/deployment/seo.spec.ts b/tests/deployment/seo.spec.ts index cc37943..3dfc67a 100644 --- a/tests/deployment/seo.spec.ts +++ b/tests/deployment/seo.spec.ts @@ -11,6 +11,8 @@ const alternate = 'http://www.portfolio.example/'; const escapedTitle = 'Luca Pîslaru — System Administrator & Full-Stack Developer'; const description = 'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.'; const socialImage = `${canonical}assets/project-social-preview.webp`; +const projectStillMaxBytes = 400 * 1024; +const socialImageFixture = Buffer.alloc(15_068); const html = ` ${escapedTitle} @@ -36,6 +38,8 @@ interface FixtureOptions { html?: string; imageStatus?: number; imageType?: string; + imageContentLength?: string | null; + imageChunks?: readonly Buffer[]; } const mutateJsonLd = (source: string, mutate: (graph: Record) => void): string => @@ -78,12 +82,19 @@ async function fixtureFetch(options: FixtureOptions = {}) { } if (url.pathname === '/assets/project-social-preview.webp') { const status = options.imageStatus ?? 200; - response.writeHead(status, { + const chunks = status === 200 ? options.imageChunks ?? [socialImageFixture] : []; + const contentLength = options.imageContentLength === undefined + ? String(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)) + : options.imageContentLength; + const headers: Record = { ...base, 'content-type': options.imageType ?? 'image/webp', 'cache-control': 'public, max-age=31536000, immutable', - }); - response.end(status === 200 ? Buffer.from('social-preview-fixture') : undefined); + }; + if (contentLength !== null) headers['content-length'] = contentLength; + response.writeHead(status, headers); + for (const chunk of chunks) response.write(chunk); + response.end(); return; } if (url.pathname === '/assets/demo.12345678.webm') { @@ -224,4 +235,36 @@ describe('production smoke and SEO contract', () => { fetchImpl, })).rejects.toThrow(error); }); + + it.each([ + { + name: 'a zero-byte body', + options: { imageChunks: [], imageContentLength: '0' }, + error: /social image.*empty|zero-byte/iu, + }, + { + name: 'a declared body above the project-still limit', + options: { + imageChunks: [Buffer.alloc(projectStillMaxBytes + 1)], + imageContentLength: String(projectStillMaxBytes + 1), + }, + error: /social image.*(?:limit|large|400)/iu, + }, + { + name: 'an undeclared chunked body above the project-still limit', + options: { + imageChunks: [Buffer.alloc(projectStillMaxBytes), Buffer.alloc(1)], + imageContentLength: null, + }, + error: /social image.*(?:limit|large|400)/iu, + }, + ])('rejects $name without buffering the social image', async ({ options, error }) => { + const fetchImpl = await fixtureFetch(options); + + await expect(verifyProductionSmoke({ + canonicalUrl: canonical, + expectedManifestDigest: digest, + fetchImpl, + })).rejects.toThrow(error); + }); });