/** * The headers the built site actually sends. PLAN.md §6 / D48, phase 10. * * --------------------------------------------------------------------------------------- * WHY THIS IS A TEST AND NOT A CHECK SCRIPT * --------------------------------------------------------------------------------------- * `scripts/checkCsp.mjs` reads `dist/_headers.json` and proves the build computed the right * policy for every route. That is necessary and it is not sufficient, because the defect * this file exists for happened entirely *after* the build was correct: `@astrojs/node` * matched a request to a policy with `pathname.includes(...)`, a substring test, and served * `/modules/` the policy built for `/docs/modules/building-a-module`. Every file on disk was * right. The bytes on the wire were not. * * Nothing that reads `dist/` can see that. The only way to know what a reader receives is * to start the server and ask it, so this starts `scripts/serve.mjs` on an ephemeral port * and reads the responses. * * The symptom is worth restating, because it is what makes this worth a test rather than a * comment: a page served another page's hash list renders with its own stylesheet REFUSED. * `/modules/` and `/architecture/` were shipping unstyled sections, and the only trace was * a console message. The homepage looked perfect throughout — it happened to share a hash * with the 404 page it was being given. * * --------------------------------------------------------------------------------------- * IT NEEDS A BUILD * --------------------------------------------------------------------------------------- * `dist/` is an input here, so this file is NOT in `npm test` — that runs before the build, * both locally and in CI. It is `npm run test:served`, which `npm run verify` runs after * `npm run build`. With no build present it skips rather than fails, so that a developer * running the whole file by hand gets an explanation instead of a stack trace. */ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { after, before, describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const built = fs.existsSync(path.join(root, 'dist', 'server', 'entry.mjs')); const PORT = 41732; const base = `http://127.0.0.1:${PORT}`; let server; /** Wait for the port to answer rather than sleeping a guessed number of milliseconds. */ async function waitForServer(timeoutMs = 30000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { await fetch(base + '/', { signal: AbortSignal.timeout(1000) }); return; } catch { await new Promise((resolve) => setTimeout(resolve, 200)); } } throw new Error(`serve.mjs did not answer on ${base} within ${timeoutMs}ms`); } describe('the headers the server sends', { skip: built ? false : 'no build in dist/ — run npm run build first' }, () => { before(async () => { server = spawn(process.execPath, [path.join(root, 'scripts', 'serve.mjs')], { cwd: root, env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1' }, stdio: 'ignore', }); await waitForServer(); }); after(() => server?.kill()); const cspOf = async (route) => { const res = await fetch(base + route); assert.equal(res.status, route === '/404' ? 404 : 200, `${route} status`); const csp = res.headers.get('content-security-policy'); assert.ok(csp, `${route} has no Content-Security-Policy header`); return csp; }; it('gives each prerendered route its OWN policy, not a substring match', async () => { // The exact pair the upstream bug confused: one is a substring of the other. const marketing = await cspOf('/modules/'); const docs = await cspOf('/docs/modules/building-a-module/'); assert.notEqual(marketing, docs, '/modules/ was served the docs page\'s policy'); // And the homepage, which matched whichever record came first in the file. const home = await cspOf('/'); const notFound = await cspOf('/404'); assert.notEqual(home, notFound, '/ was served the 404 page\'s policy'); }); it("covers a page's own inline styles with hashes in the policy it is served", async () => { for (const route of ['/', '/modules/', '/architecture/', '/docs/']) { const csp = await cspOf(route); const html = await (await fetch(base + route)).text(); let counted = 0; for (const [, body] of html.matchAll(/]*>([\s\S]*?)<\/style>/g)) { if (body.trim() === '') continue; counted++; const hash = `sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}`; assert.ok(csp.includes(hash), `${route}: an inline