Files
vue-journal-layer/testing/purge-token-check.mjs
Jakub Zych e0cc87118c fix: keep the site cache purge token out of the page payload (NUXT_CACHE_PURGE_TOKEN)
nuxt-site-config copies every NUXT_SITE_* variable into the public site
config, so NUXT_SITE_CACHE_PURGE_TOKEN reached every page's payload as
cachePurgeToken (and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied).

- The purge settings move to runtimeConfig cachePurge.token
  (NUXT_CACHE_PURGE_TOKEN) and cachePurge.allowProxied
  (NUXT_CACHE_PURGE_ALLOW_PROXIED), outside that prefix.
- The old siteCache.* names keep working for one release: a non-empty
  new token wins, either allowProxied flag turns it on, and one
  deprecation warning per process names the variables (never a value).
- The shared Nitro plugin server/plugins/site-cache-purge.ts strips
  cachePurgeToken and cacheAllowProxied from nuxt-site-config's stack on
  site-config:init, so a site on the old names leaks nothing either; it
  does nothing without nuxt-site-config.
- The shared lib, route and plugin and tests/sitePurge.test.ts are
  byte-identical in vue-journal-layer, vue-menu-layer and vue-faq-layer,
  with three sha256 pins.
- README: new names, the deprecation, the stripping and the advice to
  rotate a token ever set under the old name.
2026-10-20 20:49:34 +02:00

195 lines
7.4 KiB
JavaScript

#!/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 <dir> --path <page 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 (<n> 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<string, string>} */
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<string, string>} 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<void>, 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<void> }} */ (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)
})