fix: validate social preview metadata

This commit is contained in:
2026-09-05 00:57:01 +03:00
parent eae03b2cd2
commit c958d4a05f
4 changed files with 254 additions and 14 deletions

View File

@@ -32,7 +32,23 @@ const html = `<!doctype html><html><head>
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))));
async function fixtureFetch() {
interface FixtureOptions {
html?: string;
imageStatus?: number;
imageType?: string;
}
const mutateJsonLd = (source: string, mutate: (graph: Record<string, any>) => void): string =>
source.replace(
/(<script type="application\/ld\+json">)([\s\S]*?)(<\/script>)/u,
(_match, opening: string, json: string, closing: string) => {
const graph = JSON.parse(json) as Record<string, any>;
mutate(graph);
return `${opening}${JSON.stringify(graph)}${closing}`;
},
);
async function fixtureFetch(options: FixtureOptions = {}) {
const server = createServer((request, response) => {
const original = request.headers['x-original-url'];
const url = new URL(String(original));
@@ -49,7 +65,7 @@ async function fixtureFetch() {
'x-frame-options': 'DENY',
};
if (url.pathname === '/') {
response.writeHead(200, { ...base, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=0, must-revalidate', 'content-encoding': 'br' }); response.end(brotliCompressSync(html)); return;
response.writeHead(200, { ...base, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=0, must-revalidate', 'content-encoding': 'br' }); response.end(brotliCompressSync(options.html ?? html)); return;
}
if (url.pathname === '/robots.txt') {
response.writeHead(200, { ...base, 'content-type': 'text/plain', 'cache-control': 'public, max-age=0, must-revalidate' }); response.end(`User-agent: *\nAllow: /\nSitemap: ${canonical}sitemap.xml\n`); return;
@@ -60,6 +76,16 @@ async function fixtureFetch() {
if (url.pathname === '/assets/app.12345678.js') {
response.writeHead(200, { ...base, 'content-type': 'text/javascript', 'cache-control': 'public, max-age=31536000, immutable', 'content-encoding': 'gzip' }); response.end(gzipSync('console.log("portfolio")')); return;
}
if (url.pathname === '/assets/project-social-preview.webp') {
const status = options.imageStatus ?? 200;
response.writeHead(status, {
...base,
'content-type': options.imageType ?? 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
});
response.end(status === 200 ? Buffer.from('social-preview-fixture') : undefined);
return;
}
if (url.pathname === '/assets/demo.12345678.webm') {
if (request.headers.range !== 'bytes=0-1023') { response.writeHead(416, base); response.end(); return; }
response.writeHead(206, { ...base, 'content-type': 'video/webm', 'cache-control': 'public, max-age=31536000, immutable', 'accept-ranges': 'bytes', 'content-range': 'bytes 0-1023/2048', 'content-length': '1024' }); response.end(Buffer.alloc(1024)); return;
@@ -81,11 +107,121 @@ async function fixtureFetch() {
describe('production smoke and SEO contract', () => {
it('verifies canonical metadata, response policy, compression, MIME, ranges, and one-hop redirects', async () => {
const fetchImpl = await fixtureFetch();
await expect(verifyProductionSmoke({ canonicalUrl: canonical, alternateUrls: [alternate], expectedManifestDigest: digest, videoPath: '/assets/demo.12345678.webm', fetchImpl })).resolves.toMatchObject({ manifestDigest: digest, canonicalUrl: canonical, jsDisabledContentVerified: true });
await expect(verifyProductionSmoke({ canonicalUrl: canonical, alternateUrls: [alternate], expectedManifestDigest: digest, videoPath: '/assets/demo.12345678.webm', fetchImpl })).resolves.toMatchObject({ manifestDigest: digest, canonicalUrl: canonical, jsDisabledContentVerified: true, socialImageVerified: true });
});
it('rejects a deployed digest mismatch and redirect chains', async () => {
const fetchImpl = await fixtureFetch();
await expect(verifyProductionSmoke({ canonicalUrl: canonical, alternateUrls: [alternate], expectedManifestDigest: 'b'.repeat(64), videoPath: '/assets/demo.12345678.webm', fetchImpl })).rejects.toThrow(/manifest-digest|manifest digest/u);
});
it.each([
{
name: 'a missing Twitter image tag',
html: html.replace(/<meta name="twitter:image"[^>]+>\n/u, ''),
error: /twitter:image|metadata/iu,
},
{
name: 'a missing Open Graph image tag',
html: html.replace(/<meta property="og:image"[^>]+>\n/u, ''),
error: /og:image|metadata/iu,
},
{
name: 'a duplicate Open Graph title tag',
html: html.replace('</head>', `<meta property="og:title" content="${escapedTitle}" /></head>`),
error: /exactly one og:title/iu,
},
{
name: 'a mismatched Twitter title',
html: html.replace(`<meta name="twitter:title" content="${escapedTitle}" />`, '<meta name="twitter:title" content="Different role" />'),
error: /twitter:title|metadata/iu,
},
{
name: 'a mismatched Open Graph canonical URL',
html: html.replace(`<meta property="og:url" content="${canonical}" />`, '<meta property="og:url" content="https://other.example/" />'),
error: /open graph|canonical|metadata/iu,
},
{
name: 'a duplicate Twitter card tag',
html: html.replace('</head>', '<meta name="twitter:card" content="summary_large_image" /></head>'),
error: /exactly one twitter:card/iu,
},
{
name: 'a missing Person mainEntity',
html: mutateJsonLd(html, (graph) => { delete graph.mainEntity; }),
error: /mainEntity|profilePage|json-ld/iu,
},
{
name: 'a missing public homeLocation',
html: mutateJsonLd(html, (graph) => { delete graph.mainEntity.homeLocation; }),
error: /homeLocation|location|json-ld/iu,
},
{
name: 'missing public sameAs profiles',
html: mutateJsonLd(html, (graph) => { delete graph.mainEntity.sameAs; }),
error: /sameAs|profile|json-ld/iu,
},
{
name: 'a forbidden portrait field',
html: mutateJsonLd(html, (graph) => { graph.mainEntity.image = socialImage; }),
error: /image|portrait|json-ld|structured/iu,
},
{
name: 'private address detail',
html: mutateJsonLd(html, (graph) => { graph.mainEntity.homeLocation.address.streetAddress = 'Private street'; }),
error: /address|location|json-ld|structured/iu,
},
{
name: 'a forbidden postal code',
html: mutateJsonLd(html, (graph) => { graph.mainEntity.homeLocation.address.postalCode = '000000'; }),
error: /address|location|json-ld|structured/iu,
},
{
name: 'a forbidden telephone',
html: mutateJsonLd(html, (graph) => { graph.mainEntity.telephone = '+40000000000'; }),
error: /person|json-ld|structured/iu,
},
{
name: 'a fabricated interaction statistic',
html: mutateJsonLd(html, (graph) => { graph.mainEntity.interactionStatistic = { '@type': 'InteractionCounter', userInteractionCount: 1 }; }),
error: /person|json-ld|structured/iu,
},
{
name: 'a duplicate JSON-LD graph',
html: html.replace('</head>', `${html.match(/<script type="application\/ld\+json">[\s\S]*?<\/script>/u)![0]}</head>`),
error: /exactly one json-ld/iu,
},
{
name: 'malformed JSON-LD',
html: html.replace(/(<script type="application\/ld\+json">)[\s\S]*?(<\/script>)/u, '$1{$2'),
error: /json-ld.*valid|valid json/iu,
},
{
name: 'an off-host social image',
html: html.replaceAll(socialImage, 'https://cdn.example.test/social-preview.webp'),
error: /social image|canonical host|same host/iu,
},
])('rejects $name', async ({ html: mutatedHtml, error }) => {
const fetchImpl = await fixtureFetch({ html: mutatedHtml });
await expect(verifyProductionSmoke({
canonicalUrl: canonical,
expectedManifestDigest: digest,
fetchImpl,
})).rejects.toThrow(error);
});
it.each([
{ name: 'a missing social image response', options: { imageStatus: 404 }, error: /social image.*200|social image.*return/iu },
{ name: 'a non-image social response', options: { imageType: 'text/plain' }, error: /social image.*content-type|image mime/iu },
{ name: 'a MIME that disagrees with the approved WebP', options: { imageType: 'image/png' }, error: /social image.*content-type|image mime/iu },
])('rejects $name', async ({ options, error }) => {
const fetchImpl = await fixtureFetch(options);
await expect(verifyProductionSmoke({
canonicalUrl: canonical,
expectedManifestDigest: digest,
fetchImpl,
})).rejects.toThrow(error);
});
});

View File

@@ -96,6 +96,30 @@ describe('safe metadata', () => {
});
});
it.each([
{ name: 'audio', mediaType: 'audio/mpeg' as const, fileName: 'project-social-preview.mp3', width: null, height: null, durationMs: 1000 },
{ name: 'video', mediaType: 'video/mp4' as const, fileName: 'project-social-preview.mp4', width: 1200, height: 630, durationMs: 1000 },
{ name: 'vector image', mediaType: 'image/svg+xml' as const, fileName: 'project-social-preview.svg', width: 1200, height: 630, durationMs: null },
{ name: 'wrong-size raster', mediaType: 'image/webp' as const, fileName: 'project-social-preview.webp', width: 1199, height: 630, durationMs: null },
])('falls back to a summary card when the configured preview is $name', ({ mediaType, fileName, width, height, durationMs }) => {
const unsafeManifest = structuredClone(manifest);
const previewAsset = unsafeManifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
previewAsset.mediaType = mediaType;
previewAsset.fileName = fileName;
previewAsset.width = width;
previewAsset.height = height;
previewAsset.durationMs = durationMs;
const metadata = createPageMetadata(content, unsafeManifest, productionSite);
expect(metadata.openGraph).not.toHaveProperty('image');
expect(metadata.twitter).toEqual({
card: 'summary',
title: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
description: content.metadata.description,
});
});
it('omits sameAs when no verified social profiles exist', () => {
const withoutSocial = structuredClone(content);
withoutSocial.contact.social = [];