- POST /api/__site__/cache/purge, its library and the site config plugin moved from vue-journal-layer with the same contract (token rules, 404 for proxied requests unless allowProxied, deprecated siteCache names); the route also clears the resolve cache - server/plugins/seo-cache-purge.ts clears the SEO caches after any 200 purge, so an app whose journal, menu or FAQ layer copy of the route wins keeps fresh SEO answers until those copies are removed - cachePurge and siteCache runtime keys; /api/__site__/** never cached - testing/purge-check.mjs (CMS answer change, plus a redirect added for a route-cached page applying at once) and purge-token-check.mjs
195 lines
7.4 KiB
JavaScript
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}/_seo/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)
|
|
})
|