fix: bound social preview smoke response
This commit is contained in:
@@ -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.`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user