fix: bound social preview smoke response

This commit is contained in:
2026-09-05 01:06:01 +03:00
parent c958d4a05f
commit f58b079c18
2 changed files with 102 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs';
const sha256Pattern = /^[a-f0-9]{64}$/u; const sha256Pattern = /^[a-f0-9]{64}$/u;
const privateIdentifiers = /(?:simpastil|simpasteel|neon[ -]?debugger|yourhealth)/iu; const privateIdentifiers = /(?:simpastil|simpasteel|neon[ -]?debugger|yourhealth)/iu;
@@ -144,6 +145,56 @@ async function request(fetchImpl, url, init = {}) {
return response; 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) { export async function verifyProductionSmoke(options) {
const canonical = rootHttpsUrl(options.canonicalUrl, 'Canonical URL'); const canonical = rootHttpsUrl(options.canonicalUrl, 'Canonical URL');
if (!sha256Pattern.test(options.expectedManifestDigest)) throw new Error('Expected signed manifest digest must be lowercase SHA-256.'); 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)) { if (socialImageType === null || !new RegExp(`^${publicMetadata.socialImageType}(?:;|$)`, 'u').test(socialImageType)) {
throw new Error('Production social image content-type is missing or unsupported.'); 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']) { for (const path of ['/robots.txt', '/sitemap.xml']) {
const response = await request(fetchImpl, new URL(path, canonical)); const response = await request(fetchImpl, new URL(path, canonical));
if (response.status !== 200) throw new Error(`${path} did not return 200.`); if (response.status !== 200) throw new Error(`${path} did not return 200.`);

View File

@@ -11,6 +11,8 @@ const alternate = 'http://www.portfolio.example/';
const escapedTitle = 'Luca Pîslaru — System Administrator & Full-Stack Developer'; 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 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 socialImage = `${canonical}assets/project-social-preview.webp`;
const projectStillMaxBytes = 400 * 1024;
const socialImageFixture = Buffer.alloc(15_068);
const html = `<!doctype html><html><head> const html = `<!doctype html><html><head>
<title>${escapedTitle}</title> <title>${escapedTitle}</title>
<meta name="description" content="${description}" /> <meta name="description" content="${description}" />
@@ -36,6 +38,8 @@ interface FixtureOptions {
html?: string; html?: string;
imageStatus?: number; imageStatus?: number;
imageType?: string; imageType?: string;
imageContentLength?: string | null;
imageChunks?: readonly Buffer[];
} }
const mutateJsonLd = (source: string, mutate: (graph: Record<string, any>) => void): string => const mutateJsonLd = (source: string, mutate: (graph: Record<string, any>) => void): string =>
@@ -78,12 +82,19 @@ async function fixtureFetch(options: FixtureOptions = {}) {
} }
if (url.pathname === '/assets/project-social-preview.webp') { if (url.pathname === '/assets/project-social-preview.webp') {
const status = options.imageStatus ?? 200; 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<string, string> = {
...base, ...base,
'content-type': options.imageType ?? 'image/webp', 'content-type': options.imageType ?? 'image/webp',
'cache-control': 'public, max-age=31536000, immutable', '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; return;
} }
if (url.pathname === '/assets/demo.12345678.webm') { if (url.pathname === '/assets/demo.12345678.webm') {
@@ -224,4 +235,36 @@ describe('production smoke and SEO contract', () => {
fetchImpl, fetchImpl,
})).rejects.toThrow(error); })).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);
});
}); });