diff --git a/scripts/smoke-production.mjs b/scripts/smoke-production.mjs index 909f339..4138cdc 100644 --- a/scripts/smoke-production.mjs +++ b/scripts/smoke-production.mjs @@ -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, /]*\brel=["']canonical["'])[^>]*\bhref=["']([^"']+)["'][^>]*>/giu, 'canonical link'); - const ogUrl = exactTagContent(html, /]*\bproperty=["']og:url["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:url'); - const robots = exactTagContent(html, /]*\bname=["']robots["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'robots meta'); - exactTagContent(html, /]*\bname=["']description["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'description meta'); - exactTagContent(html, /]*\bproperty=["']og:title["'])[^>]*\bcontent=["']([^"']+)["'][^>]*>/giu, 'og:title'); - exactTagContent(html, /]*\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, /]*>([\s\S]*?)<\/title>/giu, 'title'); + const canonicalLink = exactTagContent(html, /]*\brel=["']canonical["'])(?=[^>]*\bhref=["']([^"']+)["'])[^>]*>/giu, 'canonical link'); + const description = exactTagContent(html, /]*\bname=["']description["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'description meta'); + const robots = exactTagContent(html, /]*\bname=["']robots["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'robots meta'); + const ogType = exactTagContent(html, /]*\bproperty=["']og:type["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:type'); + const ogTitle = exactTagContent(html, /]*\bproperty=["']og:title["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:title'); + const ogDescription = exactTagContent(html, /]*\bproperty=["']og:description["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:description'); + const ogUrl = exactTagContent(html, /]*\bproperty=["']og:url["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:url'); + const ogImage = exactTagContent(html, /]*\bproperty=["']og:image["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'og:image'); + const twitterCard = exactTagContent(html, /]*\bname=["']twitter:card["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:card'); + const twitterTitle = exactTagContent(html, /]*\bname=["']twitter:title["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:title'); + const twitterDescription = exactTagContent(html, /]*\bname=["']twitter:description["'])(?=[^>]*\bcontent=["']([^"']+)["'])[^>]*>/giu, 'twitter:description'); + const twitterImage = exactTagContent(html, /]*\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, /]*\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) { diff --git a/src/content/metadata.ts b/src/content/metadata.ts index ebe790f..0196f4e 100644 --- a/src/content/metadata.ts +++ b/src/content/metadata.ts @@ -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, @@ -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; }; diff --git a/tests/deployment/seo.spec.ts b/tests/deployment/seo.spec.ts index 9b38a97..cc37943 100644 --- a/tests/deployment/seo.spec.ts +++ b/tests/deployment/seo.spec.ts @@ -32,7 +32,23 @@ const html = ` const servers: ReturnType[] = []; afterEach(async () => Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))))); -async function fixtureFetch() { +interface FixtureOptions { + html?: string; + imageStatus?: number; + imageType?: string; +} + +const mutateJsonLd = (source: string, mutate: (graph: Record) => void): string => + source.replace( + /(