#!/usr/bin/env node // Purge token check: proves on a built Nuxt server that the site cache purge // token never reaches a page, whichever environment variable carries it, and // that the shared purge route reads the new and the deprecated names. // // node purge-token-check.mjs --output <.output dir> --fixtures --path // // nuxt-site-config (through @nuxtjs/seo) copies every NUXT_SITE_* variable // into the public site config, which Nuxt serializes into each page's // payload. Three scenarios, each on a fresh server with fresh random tokens: // // A NUXT_CACHE_PURGE_TOKEN only // B the deprecated NUXT_SITE_CACHE_PURGE_TOKEN and // NUXT_SITE_CACHE_ALLOW_PROXIED=true only // C both token names, with different values (the new one wins) // // In each, `/`, the page and its _payload.json (when the build serves one) // must contain no token value and neither cachePurgeToken nor // cacheAllowProxied, and neither must the server's log. The purge route must // answer 200 to the expected bearer, 401 to the other or a wrong one, and a // proxied request with the bearer 200 in B (allowProxied) and 404 in A. B // logs the deprecation warning exactly once, A and C never. // // Failure messages name a key or say "token value"; they never print a // value. The last line is "purge-token-check: ok ( checks)" on success; // every failure prints "purge-token-check: FAIL ..." and exits 1. import { randomBytes } from 'node:crypto' import { resolve } from 'node:path' import { startFixtureApi } from './fixture-api.mjs' import { startNuxtServer } from './nuxt-server.mjs' const PURGE_PATH = '/api/__site__/cache/purge' const PURGE_KEYS = ['cachePurgeToken', 'cacheAllowProxied'] const DEPRECATION_LINE = /^\[site-cache\].*NUXT_CACHE_PURGE_TOKEN/gm /** @param {string[]} argv */ function parseArgs(argv) { /** @type {Record} */ const out = {} const names = new Set(['--output', '--fixtures', '--path']) for (let i = 0; i < argv.length; i++) { const arg = argv[i] if (!names.has(arg)) throw new Error(`unknown argument ${arg}`) const v = argv[++i] if (v === undefined) throw new Error(`${arg} needs a value`) out[arg.slice(2)] = v } for (const name of names) { if (!out[name.slice(2)]) throw new Error('--output, --fixtures and --path are required') } if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`) return /** @type {{ output: string, fixtures: string, path: string }} */ (out) } const token = () => randomBytes(32).toString('hex') /** @param {string} base @param {string} path */ async function get(base, path) { const res = await fetch(base + path, { redirect: 'manual', signal: AbortSignal.timeout(20000) }) return { status: res.status, body: await res.text() } } /** @param {string} base @param {Record} headers */ async function purge(base, headers) { const res = await fetch(base + PURGE_PATH, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify({ kind: 'post', slug: 'purge-token-check' }), signal: AbortSignal.timeout(20000), }) await res.text() return res.status } /** @param {string} value */ const bearer = (value) => ({ authorization: `Bearer ${value}` }) /** The payload path Nuxt serves for a page path. @param {string} path */ function payloadPath(path) { return `${path.replace(/\/+$/, '')}/_payload.json` } async function main() { const args = parseArgs(process.argv.slice(2)) /** @type {string[]} */ const failures = [] let checks = 0 /** @param {boolean} ok @param {string} what */ const check = (ok, what) => { checks++ if (!ok) failures.push(what) return ok } const a = token() const b = token() const cNew = token() const cOld = token() const scenarios = [ { name: 'A (NUXT_CACHE_PURGE_TOKEN)', env: { NUXT_CACHE_PURGE_TOKEN: a }, tokens: [a], accepted: a, refused: [token()], proxied: 404, warnings: 0, }, { name: 'B (deprecated NUXT_SITE_CACHE_PURGE_TOKEN, NUXT_SITE_CACHE_ALLOW_PROXIED=true)', env: { NUXT_SITE_CACHE_PURGE_TOKEN: b, NUXT_SITE_CACHE_ALLOW_PROXIED: 'true' }, tokens: [b], accepted: b, refused: [token()], proxied: 200, warnings: 1, }, { name: 'C (both names, different values)', env: { NUXT_CACHE_PURGE_TOKEN: cNew, NUXT_SITE_CACHE_PURGE_TOKEN: cOld }, tokens: [cNew, cOld], accepted: cNew, refused: [cOld, token()], proxied: 404, warnings: 0, }, ] const api = await startFixtureApi({ dir: resolve(args.fixtures) }) /** @type {{ url: string, close: () => Promise, output: () => string } | null} */ let nuxt = null try { for (const s of scenarios) { nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: s.env }) const base = nuxt.url // 1. No token value and no purge key in any page or payload. const paths = ['/', args.path] const page = await get(base, args.path) check(page.status === 200, `${s.name}: GET ${args.path}: status ${page.status}, want 200`) const payload = payloadPath(args.path) if ((await get(base, payload)).status === 200) paths.push(payload) for (const path of paths) { const { body } = await get(base, path) for (const [i, value] of s.tokens.entries()) { check(!body.includes(value), `${s.name}: GET ${path} contains token value #${i + 1}`) } for (const key of PURGE_KEYS) { check(!body.includes(key), `${s.name}: GET ${path} contains the key ${key}`) } } // 2. The purge route reads the expected token. const ok = await purge(base, bearer(s.accepted)) check(ok === 200, `${s.name}: purge with the expected bearer: status ${ok}, want 200`) for (const [i, value] of s.refused.entries()) { const status = await purge(base, bearer(value)) check(status === 401, `${s.name}: purge with refused bearer #${i + 1}: status ${status}, want 401`) } const none = await purge(base, {}) check(none === 401, `${s.name}: purge without a bearer: status ${none}, want 401`) const proxied = await purge(base, { ...bearer(s.accepted), 'x-forwarded-for': '203.0.113.7' }) check(proxied === s.proxied, `${s.name}: proxied purge with the expected bearer: status ${proxied}, want ${s.proxied}`) // 3. The log: the deprecation warning count, no token value, no key. await new Promise((r) => setTimeout(r, 200)) const log = nuxt.output() const warnings = (log.match(DEPRECATION_LINE) ?? []).length check(warnings === s.warnings, `${s.name}: ${warnings} deprecation warnings in the server log, want ${s.warnings}`) for (const [i, value] of s.tokens.entries()) { check(!log.includes(value), `${s.name}: the server log contains token value #${i + 1}`) } await nuxt.close() nuxt = null } } finally { if (nuxt) await /** @type {{ close: () => Promise }} */ (nuxt).close() await api.close() } if (failures.length > 0) { for (const f of failures) console.error(`purge-token-check: FAIL ${f}`) process.exit(1) } console.log(`purge-token-check: ok (${checks} checks)`) } main().catch((err) => { console.error(`purge-token-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`) process.exit(1) })