fix: validate social preview metadata
This commit is contained in:
@@ -4,6 +4,21 @@ import { pathToFileURL } from 'node:url';
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
const privateIdentifiers = /(?:simpastil|simpasteel|neon[ -]?debugger|yourhealth)/iu;
|
||||
const sourceMapReference = /(?:sourceMappingURL\s*=|["'(]\/?[^"')\s]+\.map(?:[?"')\s]|$))/iu;
|
||||
const publicMetadata = Object.freeze({
|
||||
name: 'Luca Pîslaru',
|
||||
role: 'System Administrator & Full-Stack Developer',
|
||||
title: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||||
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.',
|
||||
email: 'mailto:luca.pislaru@gmail.com',
|
||||
sameAs: Object.freeze([
|
||||
'https://github.com/LucaPisl',
|
||||
'https://gitlab.com/luca_pisl',
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
]),
|
||||
location: Object.freeze({ city: 'Bucharest', countryCode: 'RO' }),
|
||||
socialImagePath: '/assets/project-social-preview.webp',
|
||||
socialImageType: 'image/webp',
|
||||
});
|
||||
|
||||
function rootHttpsUrl(value, label) {
|
||||
let url;
|
||||
@@ -65,19 +80,63 @@ function exactTagContent(html, expression, label) {
|
||||
return decodedText(values[0]);
|
||||
}
|
||||
|
||||
function exactObjectKeys(value, expectedKeys, label) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value) ||
|
||||
JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...expectedKeys].sort())) {
|
||||
throw new Error(`${label} has missing or forbidden fields.`);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyMetadata(html, canonicalUrl) {
|
||||
const canonical = exactTagContent(html, /<link\b(?=[^>]*\brel=["']canonical["'])[^>]*\bhref=["']([^"']+)["'][^>]*>/giu, 'canonical link');
|
||||
const ogUrl = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:url["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:url');
|
||||
const robots = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']robots["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'robots meta');
|
||||
exactTagContent(html, /<meta\b(?=[^>]*\bname=["']description["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'description meta');
|
||||
exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:title["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:title');
|
||||
exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:description["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:description');
|
||||
if (canonical !== canonicalUrl || ogUrl !== canonicalUrl || !/^index\s*,\s*follow$/iu.test(robots)) throw new Error('Canonical, Open Graph, or robots metadata is inconsistent.');
|
||||
const title = exactTagContent(html, /<title\b[^>]*>([\s\S]*?)<\/title>/giu, 'title');
|
||||
const canonicalLink = exactTagContent(html, /<link\b(?=[^>]*\brel=["']canonical["'])(?=[^>]*\bhref=["']([^"']+)["'])[^>]*>/giu, 'canonical link');
|
||||
const description = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']description["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'description meta');
|
||||
const robots = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']robots["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'robots meta');
|
||||
const ogType = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:type["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:type');
|
||||
const ogTitle = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:title["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:title');
|
||||
const ogDescription = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:description["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:description');
|
||||
const ogUrl = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:url["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:url');
|
||||
const ogImage = exactTagContent(html, /<meta\b(?=[^>]*\bproperty=["']og:image["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:image');
|
||||
const twitterCard = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']twitter:card["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:card');
|
||||
const twitterTitle = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']twitter:title["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:title');
|
||||
const twitterDescription = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']twitter:description["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:description');
|
||||
const twitterImage = exactTagContent(html, /<meta\b(?=[^>]*\bname=["']twitter:image["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:image');
|
||||
if (title !== publicMetadata.title || description !== publicMetadata.description ||
|
||||
canonicalLink !== canonicalUrl || !/^index\s*,\s*follow$/iu.test(robots) ||
|
||||
ogType !== 'profile' || ogTitle !== title || ogDescription !== description || ogUrl !== canonicalLink ||
|
||||
twitterCard !== 'summary_large_image' || twitterTitle !== title || twitterDescription !== description ||
|
||||
twitterImage !== ogImage) {
|
||||
throw new Error('Canonical, title, description, Open Graph, Twitter, or robots metadata is inconsistent.');
|
||||
}
|
||||
let socialImage;
|
||||
try { socialImage = new URL(ogImage); } catch { throw new Error('Social image URL is invalid.'); }
|
||||
const canonicalOrigin = new URL(canonicalUrl);
|
||||
if (socialImage.origin !== canonicalOrigin.origin || socialImage.username || socialImage.password || socialImage.port ||
|
||||
socialImage.pathname !== publicMetadata.socialImagePath || socialImage.search || socialImage.hash) {
|
||||
throw new Error('Social image must use the expected path on the canonical host.');
|
||||
}
|
||||
const jsonLdText = exactTagContent(html, /<script\b(?=[^>]*\btype=["']application\/ld\+json["'])[^>]*>([\s\S]*?)<\/script>/giu, 'JSON-LD block');
|
||||
let jsonLd;
|
||||
try { jsonLd = JSON.parse(jsonLdText); } catch { throw new Error('JSON-LD is not valid JSON.'); }
|
||||
if (jsonLd?.['@context'] !== 'https://schema.org' || jsonLd?.name !== 'Luca Pîslaru' || jsonLd?.url !== canonicalUrl) throw new Error('JSON-LD identity or canonical URL is inconsistent.');
|
||||
return allMatches(html, /<(?:script|link)\b(?=[^>]*(?:src|href)=["'](\/assets\/[^"']+)["'])[^>]*>/giu);
|
||||
exactObjectKeys(jsonLd, ['@context', '@type', 'name', 'description', 'url', 'mainEntity'], 'ProfilePage JSON-LD');
|
||||
const person = jsonLd.mainEntity;
|
||||
exactObjectKeys(person, ['@type', 'name', 'jobTitle', 'description', 'url', 'email', 'homeLocation', 'sameAs'], 'Person JSON-LD');
|
||||
exactObjectKeys(person.homeLocation, ['@type', 'address'], 'homeLocation JSON-LD');
|
||||
exactObjectKeys(person.homeLocation.address, ['@type', 'addressLocality', 'addressCountry'], 'PostalAddress JSON-LD');
|
||||
if (jsonLd['@context'] !== 'https://schema.org' || jsonLd['@type'] !== 'ProfilePage' ||
|
||||
jsonLd.name !== publicMetadata.name || jsonLd.description !== description || jsonLd.url !== canonicalUrl ||
|
||||
person['@type'] !== 'Person' || person.name !== publicMetadata.name || person.jobTitle !== publicMetadata.role ||
|
||||
person.description !== description || person.url !== canonicalUrl || person.email !== publicMetadata.email ||
|
||||
person.homeLocation['@type'] !== 'Place' || person.homeLocation.address['@type'] !== 'PostalAddress' ||
|
||||
person.homeLocation.address.addressLocality !== publicMetadata.location.city ||
|
||||
person.homeLocation.address.addressCountry !== publicMetadata.location.countryCode ||
|
||||
JSON.stringify(person.sameAs) !== JSON.stringify(publicMetadata.sameAs)) {
|
||||
throw new Error('ProfilePage and Person JSON-LD do not match the exact public identity metadata.');
|
||||
}
|
||||
return {
|
||||
assetPaths: allMatches(html, /<(?:script|link)\b(?=[^>]*(?:src|href)=["'](\/assets\/[^"']+)["'])[^>]*>/giu),
|
||||
socialImage,
|
||||
};
|
||||
}
|
||||
|
||||
async function request(fetchImpl, url, init = {}) {
|
||||
@@ -105,7 +164,7 @@ export async function verifyProductionSmoke(options) {
|
||||
const html = await rootResponse.text();
|
||||
if (privateIdentifiers.test(html) || sourceMapReference.test(html)) throw new Error('Production HTML exposes a private identifier or source map.');
|
||||
const noJs = verifyNoJavaScriptDocument(html);
|
||||
const assetPaths = verifyMetadata(html, canonical.href);
|
||||
const { assetPaths, socialImage } = verifyMetadata(html, canonical.href);
|
||||
if (assetPaths.length === 0 || assetPaths.some((path) => path.endsWith('.map'))) throw new Error('Production HTML lacks a safe hashed asset reference.');
|
||||
const assetResponse = await request(fetchImpl, new URL(assetPaths[0], canonical), { headers: { 'accept-encoding': 'br, gzip' } });
|
||||
if (assetResponse.status !== 200) throw new Error('Production asset did not return 200.');
|
||||
@@ -115,6 +174,15 @@ export async function verifyProductionSmoke(options) {
|
||||
requireHeader(assetResponse, 'content-encoding', /^(?:br|gzip)$/u);
|
||||
const assetText = await assetResponse.text();
|
||||
if (privateIdentifiers.test(assetText) || sourceMapReference.test(assetText)) throw new Error('Production asset exposes a private identifier or source map.');
|
||||
const socialImageResponse = await request(fetchImpl, socialImage);
|
||||
if (socialImageResponse.status !== 200) throw new Error('Production social image did not return 200.');
|
||||
verifySecurityHeaders(socialImageResponse, options.expectedManifestDigest);
|
||||
requireHeader(socialImageResponse, 'cache-control', /max-age=31536000.*immutable/u);
|
||||
const socialImageType = socialImageResponse.headers.get('content-type');
|
||||
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.');
|
||||
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.`);
|
||||
@@ -135,7 +203,8 @@ export async function verifyProductionSmoke(options) {
|
||||
videoRangeVerified = true;
|
||||
}
|
||||
return { schema: 'portfolio-production-smoke/v1', canonicalUrl: canonical.href, manifestDigest: options.expectedManifestDigest,
|
||||
jsDisabledContentVerified: true, headings: noJs.headings, assetCompressionVerified: true, videoRangeVerified };
|
||||
jsDisabledContentVerified: true, headings: noJs.headings, assetCompressionVerified: true,
|
||||
socialImageVerified: true, videoRangeVerified };
|
||||
}
|
||||
|
||||
function parseArguments(args) {
|
||||
|
||||
@@ -66,6 +66,13 @@ export interface StaticHostArtifacts {
|
||||
redirects: string;
|
||||
}
|
||||
|
||||
const socialPreviewMediaTypes = new Set([
|
||||
'image/avif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const absoluteAssetUrl = (
|
||||
assetId: string | undefined,
|
||||
manifest: Readonly<PublicAssetManifest>,
|
||||
@@ -74,6 +81,10 @@ const absoluteAssetUrl = (
|
||||
if (assetId === undefined) return undefined;
|
||||
const asset = manifest.assets.find((candidate) => candidate.id === assetId);
|
||||
if (asset === undefined) throw new Error(`Unknown social preview asset: ${assetId}`);
|
||||
if (!socialPreviewMediaTypes.has(asset.mediaType) ||
|
||||
asset.width !== 1200 || asset.height !== 630 || asset.durationMs !== null) {
|
||||
return undefined;
|
||||
}
|
||||
return new URL(`/assets/${encodeURIComponent(asset.fileName)}`, canonicalUrl).href;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user