Files
Portfolio/tests/deployment/seo.spec.ts

271 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer } from 'node:http';
import { brotliCompressSync, gzipSync } from 'node:zlib';
import { afterEach, describe, expect, it } from 'vitest';
// @ts-expect-error The production smoke checker is an executable ESM module.
import { verifyProductionSmoke } from '../../scripts/smoke-production.mjs';
const digest = 'a'.repeat(64);
const canonical = 'https://portfolio.example/';
const alternate = 'http://www.portfolio.example/';
const escapedTitle = 'Luca Pîslaru — System Administrator & Full-Stack Developer';
const description = 'I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.';
const socialImage = `${canonical}assets/project-social-preview.webp`;
const projectStillMaxBytes = 400 * 1024;
const socialImageFixture = Buffer.alloc(15_068);
const html = `<!doctype html><html><head>
<title>${escapedTitle}</title>
<meta name="description" content="${description}" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="${canonical}" />
<meta property="og:type" content="profile" />
<meta property="og:title" content="${escapedTitle}" />
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${canonical}" />
<meta property="og:image" content="${socialImage}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${escapedTitle}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${socialImage}" />
<script type="application/ld+json">{"@context":"https://schema.org","@type":"ProfilePage","name":"Luca Pîslaru","description":"${description}","url":"${canonical}","mainEntity":{"@type":"Person","name":"Luca Pîslaru","jobTitle":"System Administrator & Full-Stack Developer","description":"${description}","url":"${canonical}","homeLocation":{"@type":"Place","address":{"@type":"PostalAddress","addressLocality":"Bucharest","addressCountry":"RO"}},"sameAs":["https://linkedin.com/in/luca-pisl","https://github.com/LucaPisl","https://gitlab.com/luca_pisl"]}}</script>
<script type="module" src="/assets/app.12345678.js"></script></head>
<body><main><h1>Luca Pîslaru</h1><p>System Administrator &amp; Full-Stack Developer</p><section><h2>Selected builds</h2><p>Evidence-led project work.</p></section><section><h2>Contact</h2><p>Discuss systems and software work.</p><a href="mailto:hello@example.com">Contact</a></section></main></body></html>`;
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))));
interface FixtureOptions {
html?: string;
imageStatus?: number;
imageType?: string;
imageContentLength?: string | null;
imageChunks?: readonly Buffer[];
}
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));
if (url.origin === new URL(alternate).origin) {
response.writeHead(308, { location: canonical }); response.end(); return;
}
const base = {
'x-portfolio-manifest-digest': digest,
'content-security-policy': "default-src 'self'; script-src 'self' 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests",
'strict-transport-security': 'max-age=31536000',
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
'permissions-policy': 'camera=(), microphone=(), geolocation=()',
'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(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;
}
if (url.pathname === '/sitemap.xml') {
response.writeHead(200, { ...base, 'content-type': 'application/xml', 'cache-control': 'public, max-age=0, must-revalidate' }); response.end(`<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>${canonical}</loc></url></urlset>`); return;
}
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;
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,
'content-type': options.imageType ?? 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
};
if (contentLength !== null) headers['content-length'] = contentLength;
response.writeHead(status, headers);
for (const chunk of chunks) response.write(chunk);
response.end();
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;
}
response.writeHead(404, base); response.end();
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (address === null || typeof address === 'string') throw new Error('fixture server did not bind');
return async (input: string | URL | Request, init?: RequestInit) => {
const requested = input instanceof Request ? input.url : input.toString();
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
headers.set('x-original-url', requested);
return fetch(`http://127.0.0.1:${address.port}${new URL(requested).pathname}`, { ...init, redirect: 'manual', headers });
};
}
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, 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);
});
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);
});
});