diff --git a/nuxt.config.ts b/nuxt.config.ts index 5dcfe66..bbb59d7 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -49,6 +49,32 @@ export default defineNuxtConfig({ // turns the cache off. resolveCacheTtl: 60, }, + // The shared site cache purge route POST /api/__site__/cache/purge + // (moved here from the journal, menu and FAQ layers, same contract). + cachePurge: { + // Server-only bearer token (NUXT_CACHE_PURGE_TOKEN), the backend's + // site_cache.purge_token. Shorter than 32 characters, the route + // answers 404. Not under NUXT_SITE_: nuxt-site-config copies every + // NUXT_SITE_* variable into the site config, which is published in + // every page's payload. + token: '', + // NUXT_CACHE_PURGE_ALLOW_PROXIED: set it to true when the backend + // reaches the site through a TLS-terminating proxy (an https + // site_cache.purge_url on another host), which adds X-Forwarded-For, + // X-Real-IP or Forwarded. The route then accepts proxied requests and + // the bearer token is the only gate; unset, it answers 404 to any + // proxied request. + allowProxied: false, + }, + // Deprecated (one release): the earlier names of cachePurge.token + // (NUXT_SITE_CACHE_PURGE_TOKEN) and cachePurge.allowProxied + // (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new ones are unset. The + // layer's server/plugins/site-cache-purge.ts strips them from the site + // config and logs a deprecation warning. + siteCache: { + purgeToken: '', + allowProxied: false, + }, public: { seo: { // Same-origin base for browser navigations (NUXT_PUBLIC_SEO_API_BASE). @@ -63,7 +89,13 @@ export default defineNuxtConfig({ }, }, + // The layer's routes and the shared site routes are never route-cached. + // Nitro wraps every handler matching an app rule such as '/**': { swr } in + // its cached handler, which keys by path, does not skip POST and hides + // request headers, so the purge route would answer from the cache without + // running. routeRules: { '/api/__seo__/**': { cache: false }, + '/api/__site__/**': { cache: false }, }, }) diff --git a/server/api/__site__/cache/purge.post.ts b/server/api/__site__/cache/purge.post.ts new file mode 100644 index 0000000..0b38719 --- /dev/null +++ b/server/api/__site__/cache/purge.post.ts @@ -0,0 +1,55 @@ +// POST /api/__site__/cache/purge: removes every Nitro route-cache entry +// (pages, _payload.json and feeds), so an admin save shows on the next page +// view. The backend calls it on loopback after the save commits, with +// `Authorization: Bearer ` +// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"","slug":""}; +// the answer is {"purged":}. The kind and slug only reach the log line. +// +// 404 without a token of at least 32 characters, and for any request that +// came through a reverse proxy (X-Forwarded-For, X-Real-IP or Forwarded) +// unless runtimeConfig.cachePurge.allowProxied +// (NUXT_CACHE_PURGE_ALLOW_PROXIED) is true or "true": a backend that reaches +// the site through a TLS-terminating proxy opts in, and the bearer token is +// then the only gate. 401 for a missing or wrong bearer token. The +// '/api/__site__/**' route rule keeps this route out of the route cache. +// +// Deprecated for one release: siteCache.purgeToken +// (NUXT_SITE_CACHE_PURGE_TOKEN) and siteCache.allowProxied +// (NUXT_SITE_CACHE_ALLOW_PROXIED), read when the new names are unset (see +// resolveSitePurgeConfig). +// +// The purge also empties the SEO layer's caches (the resolve answers and +// the site answer), so a purged page renders with a fresh CMS answer. +// +// Moved here from vue-journal-layer, vue-menu-layer and vue-faq-layer, which +// ship an older copy of this file until they extend this layer; Nitro keeps +// one handler per route and method, so server/plugins/seo-cache-purge.ts +// also clears the SEO caches when one of those copies answers. + +import { clearSeoCaches } from '../../../lib/resolve.ts' +import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache, resolveSitePurgeConfig } from '../../../lib/siteCachePurge.ts' + +const PROXY_HEADERS = ['x-forwarded-for', 'x-real-ip', 'forwarded'] + +export default defineEventHandler(async (event) => { + setResponseHeader(event, 'cache-control', 'no-store') + const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event)) + const verdict = authorizeSitePurge({ + token, + authorization: getRequestHeader(event, 'authorization'), + proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined), + allowProxied, + }) + if (verdict === 'disabled' || verdict === 'proxied') { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + if (verdict === 'unauthorized') { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + + const { kind, slug } = parseSitePurgeEvent(await readBody(event).catch(() => null)) + const purged = await purgeSiteRouteCache(useStorage('cache')) + clearSeoCaches() + console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`) + return { purged } +}) diff --git a/server/lib/resolve.ts b/server/lib/resolve.ts index 1a19afc..a51c530 100644 --- a/server/lib/resolve.ts +++ b/server/lib/resolve.ts @@ -36,6 +36,11 @@ export function clearSeoCaches(): void { cache?.clear() } +/** How many answers the layer's caches hold (tests and diagnostics). */ +export function seoCacheSizes(): { resolve: number } { + return { resolve: cache?.size ?? 0 } +} + /** The x-forwarded-for value the SEO calls carry for this request. */ export function seoForwardedFor(event: H3Event): Record { const recorded = event.context.seoForwardedFor as string | undefined diff --git a/server/lib/siteCachePurge.ts b/server/lib/siteCachePurge.ts new file mode 100644 index 0000000..a7e41d5 --- /dev/null +++ b/server/lib/siteCachePurge.ts @@ -0,0 +1,208 @@ +// Shared site cache purge: the authorization rules of the purge route +// POST /api/__site__/cache/purge, the purge of Nitro's route cache, the +// parsing of the purge request body, the purge settings read from runtime +// config (new and deprecated names) and the removal of the purge keys from +// nuxt-site-config's site config. Pure TypeScript outside Nitro's and Nuxt's +// auto-import directories: vue-journal-layer, vue-menu-layer and +// vue-faq-layer ship this file byte for byte, and `node --test`, the route +// and the Nitro plugin import it directly. + +import { createHash, timingSafeEqual } from 'node:crypto' + +/** The shortest purge token the route accepts; with a shorter one it is off. */ +export const MIN_SITE_PURGE_TOKEN_LENGTH = 32 + +/** The cache storage base of Nitro's route cache (routeRules cache and swr). */ +export const SITE_ROUTE_CACHE_BASE = 'nitro:routes' + +/** Longest slug the purge log line carries. */ +const MAX_SLUG_LENGTH = 200 + +/** A purge kind: lower-case letters and underscores, up to 40 characters. */ +const KIND_PATTERN = /^[a-z_]{1,40}$/ + +export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized' + +export interface SitePurgeAuthInput { + /** The configured token (see resolveSitePurgeConfig). */ + token: unknown + /** The request's Authorization header, if any. */ + authorization?: string | null + /** True when the request carried X-Forwarded-For, X-Real-IP or Forwarded. */ + proxied: boolean + /** + * The allowProxied setting (see resolveSitePurgeConfig): when true a + * proxied request is not refused, and the bearer token is the only gate. + * For a backend that reaches the site through a TLS-terminating proxy, + * which adds forwarding headers. + */ + allowProxied?: boolean +} + +function digest(value: string): Buffer { + return createHash('sha256').update(value, 'utf8').digest() +} + +/** + * Decides a purge request: `disabled` when no token of at least + * MIN_SITE_PURGE_TOKEN_LENGTH characters is configured, `proxied` when the + * request came through a reverse proxy (checked before the token) unless + * allowProxied is true, `ok` when the Bearer value equals the token (compared + * in constant time over sha256 digests) and `unauthorized` otherwise. With + * allowProxied the token is the only gate, so it must stay long and secret. + */ +export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict { + if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled' + if (proxied && allowProxied !== true) return 'proxied' + const m = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(authorization ?? '') + if (!m) return 'unauthorized' + return timingSafeEqual(digest(m[1]), digest(token)) ? 'ok' : 'unauthorized' +} + +/** The part of a Nitro/unstorage storage the purge uses. */ +export interface SitePurgeStorage { + getKeys(base?: string): Promise | string[] + removeItem(key: string): Promise | unknown +} + +/** + * Removes every entry of Nitro's route cache (pages, their _payload.json and + * feeds) from storage, which is `useStorage('cache')`, and returns how many + * it removed. Other cache groups (cached functions, handlers) stay. + */ +export async function purgeSiteRouteCache(storage: SitePurgeStorage): Promise { + const keys = await storage.getKeys(SITE_ROUTE_CACHE_BASE) + let removed = 0 + for (const key of keys) { + if (!key.startsWith(SITE_ROUTE_CACHE_BASE + ':')) continue + await storage.removeItem(key) + removed++ + } + return removed +} + +export interface SitePurgeEvent { + kind: string + slug: string +} + +/** + * Reads the kind and slug of a purge request body for the log line: kind is + * kept when it is lower-case letters and underscores up to 40 characters + * (faq, menu, menu_entry, post, category, ...), else it is unknown; the slug + * loses control characters and is cut to 200 characters. + */ +export function parseSitePurgeEvent(body: unknown): SitePurgeEvent { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return { kind: 'unknown', slug: '' } + const raw = body as Record + const kind = typeof raw.kind === 'string' && KIND_PATTERN.test(raw.kind) ? raw.kind : 'unknown' + // eslint-disable-next-line no-control-regex + const slug = typeof raw.slug === 'string' ? raw.slug.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, MAX_SLUG_LENGTH) : '' + return { kind, slug } +} + +/** + * The site config keys nuxt-site-config derives from the deprecated + * variables NUXT_SITE_CACHE_PURGE_TOKEN and NUXT_SITE_CACHE_ALLOW_PROXIED: it + * copies every NUXT_SITE_* variable into the site config, camel-cased, and + * Nuxt serializes the site config into every page's payload. + */ +export const SITE_CONFIG_PURGE_KEYS: readonly string[] = Object.freeze(['cachePurgeToken', 'cacheAllowProxied']) + +/** + * The globalThis flag that keeps the deprecation warning to one per process, + * however many layers ship the Nitro plugin. + */ +export const SITE_PURGE_WARNED = Symbol.for('summercms.siteCachePurge.deprecationWarned') + +export type SitePurgeDeprecated = 'token' | 'allowProxied' + +export interface SitePurgeConfig { + /** The token the route checks; authorizeSitePurge decides whether it is usable. */ + token: unknown + /** True when a proxied request may reach the token check. */ + allowProxied: boolean + /** The settings that came from a deprecated name. */ + deprecated: SitePurgeDeprecated[] +} + +function section(config: unknown, name: string): Record { + if (config === null || typeof config !== 'object') return {} + const value = (config as Record)[name] + return value !== null && typeof value === 'object' ? (value as Record) : {} +} + +const isTrue = (value: unknown) => value === true || value === 'true' + +/** + * Reads the purge settings from runtime config. The current names are + * `cachePurge.token` (NUXT_CACHE_PURGE_TOKEN) and `cachePurge.allowProxied` + * (NUXT_CACHE_PURGE_ALLOW_PROXIED). Deprecated for one release: + * `siteCache.purgeToken` (NUXT_SITE_CACHE_PURGE_TOKEN) and + * `siteCache.allowProxied` (NUXT_SITE_CACHE_ALLOW_PROXIED). A non-empty new + * token wins over the old one; allowProxied is on when either flag is true or + * "true". `deprecated` lists the settings taken from an old name. + */ +export function resolveSitePurgeConfig(config: unknown): SitePurgeConfig { + const current = section(config, 'cachePurge') + const old = section(config, 'siteCache') + const deprecated: SitePurgeDeprecated[] = [] + + let token: unknown = current.token + if (typeof token !== 'string' || token === '') { + if (typeof old.purgeToken === 'string' && old.purgeToken !== '') { + token = old.purgeToken + deprecated.push('token') + } else if (token === undefined) { + token = old.purgeToken + } + } + + let allowProxied = isTrue(current.allowProxied) + if (!allowProxied && isTrue(old.allowProxied)) { + allowProxied = true + deprecated.push('allowProxied') + } + return { token, allowProxied, deprecated } +} + +/** + * Removes SITE_CONFIG_PURGE_KEYS from every entry of a nuxt-site-config stack + * (the array of partial site configs it merges per request) and returns how + * many keys it removed. Anything that is not an array of objects is left + * alone and counts 0. + */ +export function redactSiteConfigStack(stack: unknown): number { + if (!Array.isArray(stack)) return 0 + let removed = 0 + for (const entry of stack) { + if (entry === null || typeof entry !== 'object') continue + for (const key of SITE_CONFIG_PURGE_KEYS) { + if (Object.prototype.hasOwnProperty.call(entry, key)) { + delete (entry as Record)[key] + removed++ + } + } + } + return removed +} + +const DEPRECATED_NAMES: Record = { + token: ['NUXT_SITE_CACHE_PURGE_TOKEN', 'NUXT_CACHE_PURGE_TOKEN'], + allowProxied: ['NUXT_SITE_CACHE_ALLOW_PROXIED', 'NUXT_CACHE_PURGE_ALLOW_PROXIED'], +} + +/** + * The one-line deprecation warning for the settings resolveSitePurgeConfig + * took from an old name, or '' when there are none. It names variables + * only, never a value. + */ +export function sitePurgeDeprecationMessage(deprecated: readonly SitePurgeDeprecated[]): string { + const names = [...new Set(deprecated)].filter((d) => d in DEPRECATED_NAMES).map((d) => `${DEPRECATED_NAMES[d][0]} -> ${DEPRECATED_NAMES[d][1]}`) + if (names.length === 0) return '' + return ( + `[site-cache] deprecated purge settings in use, rename them: ${names.join(', ')}. ` + + 'The old names work until the next minor release. Earlier layer versions published every NUXT_SITE_* value ' + + 'in the page payload through nuxt-site-config, so rotate a token that was ever set under NUXT_SITE_CACHE_PURGE_TOKEN.' + ) +} diff --git a/server/plugins/seo-cache-purge.ts b/server/plugins/seo-cache-purge.ts new file mode 100644 index 0000000..9dacf15 --- /dev/null +++ b/server/plugins/seo-cache-purge.ts @@ -0,0 +1,22 @@ +// Clears the SEO layer's caches after every successful site cache purge, +// whichever layer's copy of POST /api/__site__/cache/purge answered it. +// +// vue-journal-layer, vue-menu-layer and vue-faq-layer ship their own copy of +// the shared purge route until they extend this layer, and Nitro keeps one +// handler per route and method, so in an app that extends several of them +// this layer's route (which clears the caches itself) may not be the one +// that runs. The journal's deprecated POST /api/__journal__/cache/purge +// purges the route cache too, so it clears them as well. Clearing twice is +// harmless. + +import { clearSeoCaches } from '../lib/resolve.ts' + +const PURGE_PATHS = new Set(['/api/__site__/cache/purge', '/api/__journal__/cache/purge']) + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('afterResponse', (event) => { + if (event.method !== 'POST') return + if (!PURGE_PATHS.has((event.path.split('?')[0] ?? '').replace(/\/+$/, ''))) return + if (event.node.res.statusCode === 200) clearSeoCaches() + }) +}) diff --git a/server/plugins/site-cache-purge.ts b/server/plugins/site-cache-purge.ts new file mode 100644 index 0000000..a35e319 --- /dev/null +++ b/server/plugins/site-cache-purge.ts @@ -0,0 +1,48 @@ +// Keeps the site cache purge settings out of the public site config. +// +// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and +// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased, +// and Nuxt serializes the site config into every page's payload (and into +// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's +// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as +// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied. +// The settings now live under NUXT_CACHE_PURGE_TOKEN and +// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep +// working for one release, so this plugin also removes the two keys from +// every entry of the site config stack on nuxt-site-config's +// `site-config:init` hook, before anything reads it. The stripping stays +// with the new names too: a site that still sets an old variable, or sets +// both, must not publish it. +// +// When a setting comes from an old name, the plugin logs one deprecation +// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending +// several layers that ship this file warns once). The warning names +// variables, never a value. +// +// Without nuxt-site-config the hook is never called and the plugin does +// nothing else. Shared: several layers ship this file byte for byte; +// running it more than once is harmless. + +import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts' + +interface SiteConfigInitContext { + siteConfig?: { stack?: unknown } +} + +type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown } + +export default defineNitroPlugin((nitroApp) => { + const g = globalThis as Record + if (!g[SITE_PURGE_WARNED]) { + const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated) + if (message) { + g[SITE_PURGE_WARNED] = true + console.warn(message) + } + } + + // The hook name is untyped in an app without nuxt-site-config. + ;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => { + redactSiteConfigStack(ctx?.siteConfig?.stack) + }) +}) diff --git a/testing/purge-check.mjs b/testing/purge-check.mjs new file mode 100644 index 0000000..a0632b4 --- /dev/null +++ b/testing/purge-check.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +// Site cache purge check: proves on a built Nuxt server with swr route rules +// that a page cached before a CMS change shows the change on the first +// request after a purge (the purge empties Nitro's route cache and the SEO +// layer's resolve cache), and that the purge route refuses unauthenticated, +// wrong-token and proxied requests. +// +// node purge-check.mjs --output <.output dir> --fixtures --path +// +// The CMS change is a new title in the fixture resolve answer for the path +// (/seo/resolve.json in a temporary copy). It also checks that a +// redirect the CMS adds for a route-cached page applies at once when the +// resolve cache is off (NUXT_SEO_RESOLVE_CACHE_TTL=0): the redirect decision +// runs before the route cache. +// +// It also checks that a cached page revalidates with its ETag (304), that +// a second purge after re-caching runs (the route is never cached itself) +// and that a server without a token answers 404. vue-journal-layer's copy +// also checks browser max-age=0 (its journal-revalidate plugin) and the +// page's _payload.json (which carries Journal data); neither applies here: +// the SEO answer lives in the page's state, not in useAsyncData. +// The fixtures are copied to a temporary directory, so the source fixtures +// never change. The last line is "purge-check: ok ( checks)" on success; +// every failure prints "purge-check: FAIL ..." and exits 1. + +import { randomBytes } from 'node:crypto' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { startFixtureApi } from './fixture-api.mjs' +import { startNuxtServer } from './nuxt-server.mjs' + +const PURGE_PATH = '/api/__site__/cache/purge' + +/** @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) +} + +/** + * Changes the fixture resolve answer for `path`: appends `marker` to its + * title, or (with `redirectTo`) turns it into a 301 redirect. + * @param {string} dir @param {string} path @param {string} marker @param {string} [redirectTo] + */ +function changeAnswer(dir, path, marker, redirectTo) { + const file = join(dir, 'seo', 'resolve.json') + const answers = JSON.parse(readFileSync(file, 'utf8')) + const answer = answers[path] + if (!answer || answer.action !== 'ok') throw new Error(`no ok answer for ${path} in ${file}`) + if (redirectTo) { + answers[path] = { action: 'redirect', status: 301, location: redirectTo, url: answer.url, locale: '', staging: false, seo: null } + } else { + answer.seo.title = `${answer.seo.title} ${marker}` + } + writeFileSync(file, JSON.stringify(answers, null, 2)) +} + +/** @param {string} base @param {string} path @param {Record} [headers] */ +async function get(base, path, headers = {}) { + const res = await fetch(base + path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) }) + return { status: res.status, headers: res.headers, body: await res.text() } +} + +/** @param {string} base @param {{ kind: string, slug: string }} event @param {Record} headers */ +async function purge(base, event, headers) { + const res = await fetch(base + PURGE_PATH, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(event), + signal: AbortSignal.timeout(20000), + }) + const text = await res.text() + /** @type {any} */ + let json = null + try { + json = JSON.parse(text) + } catch { + json = null + } + return { status: res.status, json, text } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const event = { kind: 'seo', slug: args.path } + const dir = mkdtempSync(join(tmpdir(), 'purge-check-')) + cpSync(resolve(args.fixtures), dir, { recursive: true }) + const token = randomBytes(24).toString('hex') + const auth = { authorization: `Bearer ${token}` } + const marker = `purge-check-marker-${randomBytes(6).toString('hex')}` + const second = `purge-check-second-${randomBytes(6).toString('hex')}` + + /** @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 api = await startFixtureApi({ dir }) + /** @type {{ url: string, close: () => Promise } | null} */ + let nuxt = null + // The steps stop at the first failure that makes the later ones + // meaningless; the failures are reported after the servers stop. + const steps = async () => { + nuxt = await startNuxtServer({ + outputDir: args.output, + apiBase: `${api.url}/_seo/api/v1`, + env: { NUXT_CACHE_PURGE_TOKEN: token }, + }) + const base = nuxt.url + const page = args.path + + // 1. The page renders and is cached. + const first = await get(base, page) + if (!check(first.status === 200, `GET ${page} before the change: status ${first.status}, want 200`)) return + + // 2. Change the answer behind the API; the cached page must not show it. + changeAnswer(dir, page, marker) + api.reload() + const cached = await get(base, page) + check(cached.status === 200, `GET ${page} after the change: status ${cached.status}, want 200`) + if (!check(!cached.body.includes(marker), `GET ${page} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return + + // 3. An authorized purge removes the cached entries. + const purged = await purge(base, event, auth) + check(purged.status === 200, `authorized purge: status ${purged.status}, want 200 (${purged.text.slice(0, 200)})`) + check(typeof purged.json?.purged === 'number' && purged.json.purged >= 1, `authorized purge: answer ${purged.text.slice(0, 200)}, want purged >= 1`) + + // 4. The next request shows the change (route cache and resolve cache + // both emptied) and revalidates with its ETag. + const fresh = await get(base, page) + check(fresh.status === 200 && fresh.body.includes(marker), `GET ${page} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`) + const etag = fresh.headers.get('etag') + if (check(!!etag, `GET ${page} after the purge: no ETag`)) { + const revalidated = await get(base, page, { 'if-none-match': /** @type {string} */ (etag) }) + check(revalidated.status === 304, `conditional GET ${page}: status ${revalidated.status}, want 304`) + } + + // 5. A second purge after the page was cached again runs too: the purge + // route itself is never route-cached. + changeAnswer(dir, page, second) + api.reload() + const recached = await get(base, page) + check(!recached.body.includes(second), `GET ${page} after the second change: shown before the second purge (page not re-cached)`) + const again = await purge(base, event, auth) + check(again.status === 200 && typeof again.json?.purged === 'number' && again.json.purged >= 1, `second purge: status ${again.status}, answer ${again.text.slice(0, 200)}, want 200 with purged >= 1`) + const fresh2 = await get(base, page) + check(fresh2.body.includes(second), `GET ${page} after the second purge: the second change is not shown (the purge answer came from a cache)`) + + // 6. Refused purges. + const none = await purge(base, event, {}) + check(none.status === 401, `purge without a token: status ${none.status}, want 401`) + const wrong = await purge(base, event, { authorization: `Bearer ${randomBytes(24).toString('hex')}` }) + check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`) + const proxied = await purge(base, event, { ...auth, 'x-forwarded-for': '203.0.113.7' }) + check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`) + const realIP = await purge(base, event, { ...auth, 'x-real-ip': '203.0.113.7' }) + check(realIP.status === 404, `purge with X-Real-IP and the right token: status ${realIP.status}, want 404`) + + // 7. A server without a token has no purge route. + await nuxt.close() + nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: { NUXT_CACHE_PURGE_TOKEN: '' } }) + const disabled = await purge(nuxt.url, event, auth) + check(disabled.status === 404, `purge on a server without a token: status ${disabled.status}, want 404`) + + // 8. Without the resolve cache, a redirect the CMS adds for a + // route-cached page applies on the next request, without a purge. + await nuxt.close() + nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: { NUXT_SEO_RESOLVE_CACHE_TTL: '0' } }) + const before = await get(nuxt.url, page) + check(before.status === 200, `GET ${page} before the redirect: status ${before.status}, want 200`) + changeAnswer(dir, page, '', 'https://moved.test/elsewhere') + api.reload() + const moved = await get(nuxt.url, page) + check( + moved.status === 301 && moved.headers.get('location') === 'https://moved.test/elsewhere', + `GET ${page} after the CMS added a redirect: status ${moved.status}, Location ${moved.headers.get('location')}, want 301 to https://moved.test/elsewhere`, + ) + } + try { + await steps() + } finally { + if (nuxt) await /** @type {{ close: () => Promise }} */ (nuxt).close() + await api.close() + rmSync(dir, { recursive: true, force: true }) + } + + if (failures.length > 0) { + for (const f of failures) console.error(`purge-check: FAIL ${f}`) + process.exit(1) + } + console.log(`purge-check: ok (${checks} checks)`) +} + +main().catch((err) => { + console.error(`purge-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +}) diff --git a/testing/purge-token-check.mjs b/testing/purge-token-check.mjs new file mode 100644 index 0000000..2fd6e01 --- /dev/null +++ b/testing/purge-token-check.mjs @@ -0,0 +1,194 @@ +#!/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}/_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 }} */ (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) +}) diff --git a/tests/sitePurge.test.ts b/tests/sitePurge.test.ts new file mode 100644 index 0000000..ca9e258 --- /dev/null +++ b/tests/sitePurge.test.ts @@ -0,0 +1,485 @@ +// Shared site cache purge: route authorization, the route-cache purge, the +// purge body parsing, the purge settings under their new and deprecated +// names, the site config stripping plugin, the pinned hashes of the shared +// files and the clearing of the SEO layer's caches. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { + MIN_SITE_PURGE_TOKEN_LENGTH, + SITE_CONFIG_PURGE_KEYS, + SITE_PURGE_WARNED, + SITE_ROUTE_CACHE_BASE, + authorizeSitePurge, + parseSitePurgeEvent, + purgeSiteRouteCache, + redactSiteConfigStack, + resolveSitePurgeConfig, + sitePurgeDeprecationMessage, +} from '../server/lib/siteCachePurge.ts' + +// The sha256 of the three shared files. The library and the plugin are the +// bytes vue-journal-layer, vue-menu-layer and vue-faq-layer ship; the route +// is theirs plus the clearing of the SEO caches (they drop their copies once +// they extend this layer). +const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a' +const SHARED_ROUTE_SHA256 = 'e08f7eee149fed49f3f32ec7d91c19cd25d75938a0ce80f92bdb69ddc7697eba' +const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6' + +const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + +function sha256(relative: string): string { + return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex') +} + +test('sitePurge: the shared files match the pinned hashes', () => { + assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts') + assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts') + assert.equal(sha256('../server/plugins/site-cache-purge.ts'), SHARED_PLUGIN_SHA256, 'server/plugins/site-cache-purge.ts') +}) + +test('sitePurge: a missing or short token disables the route', () => { + for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) { + assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`) + } + assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled') + assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32) +}) + +test('sitePurge: a proxied request is refused before the token is checked', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied') +}) + +test('sitePurge: authorize ok with the token and unauthorized otherwise', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok') + for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`) + } +}) + +/** A fake unstorage prefix storage over a plain key list. */ +function fakeStorage(keys: string[]) { + const live = new Set(keys) + const removed: string[] = [] + return { + removed, + live, + async getKeys(base?: string) { + return [...live].filter((k) => !base || k === base || k.startsWith(base + ':')) + }, + async removeItem(key: string) { + live.delete(key) + removed.push(key) + }, + } +} + +test('sitePurge: purge removes only nitro:routes: keys', async () => { + const routes = [ + `${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`, + `${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`, + `${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`, + ] + const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json'] + const storage = fakeStorage([...routes, ...others]) + assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes') + assert.equal(await purgeSiteRouteCache(storage), routes.length) + assert.deepEqual([...storage.removed].sort(), [...routes].sort()) + assert.deepEqual([...storage.live].sort(), [...others].sort()) + assert.equal(await purgeSiteRouteCache(storage), 0) +}) + +test('sitePurge: purge event kinds and slugs', () => { + for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) { + assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' }) + } + for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) { + assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind)) + } + assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' }) + assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200) + for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) { + assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body)) + } +}) + +test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => { + // timingSafeEqual on raw strings throws on a length mismatch; over digests + // every length compares in constant time and answers unauthorized. + for (const length of [1, 31, 33, 64, 4096]) { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`) + } + const long = 'k'.repeat(200) + assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized') +}) + +test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => { + const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json']) + const removed: string[] = [] + const storage = { + getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')), + removeItem: (key: string) => { + live.delete(key) + removed.push(key) + }, + } + assert.equal(await purgeSiteRouteCache(storage), 1) + assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`]) + assert.ok(live.has(SITE_ROUTE_CACHE_BASE)) + assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0) +}) + +test('sitePurge: purge event edge cases', () => { + assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' }) + assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' }) + assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' }) + assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' }) + assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200) + // Control characters are stripped before the cut. + const slug = '\u0001'.repeat(50) + 'z'.repeat(200) + assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200)) + assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' }) +}) + +/** One call of the shared route with Nitro's auto-imports stubbed. */ +async function callRoute(opts: { + token?: unknown + allowProxied?: unknown + cachePurge?: { token?: unknown; allowProxied?: unknown } + headers?: Record + body?: unknown + bodyError?: boolean + keys?: string[] +}) { + const responseHeaders: Record = {} + const logs: string[] = [] + const live = new Set(opts.keys ?? []) + const g = globalThis as Record + g.defineEventHandler = (fn: unknown) => fn + g.setResponseHeader = (_e: unknown, name: string, value: string) => { + responseHeaders[name] = value + } + g.useRuntimeConfig = () => ({ + siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied }, + cachePurge: opts.cachePurge, + }) + g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name] + g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input) + g.readBody = async () => { + if (opts.bodyError) throw new Error('invalid JSON') + return opts.body + } + g.useStorage = (base: string) => { + assert.equal(base, 'cache') + return { + getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')), + removeItem: async (key: string) => { + live.delete(key) + }, + } + } + const info = console.info + console.info = (line: string) => { + logs.push(line) + } + try { + const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise + try { + return { result: await route({}), status: 200, responseHeaders, logs, live } + } catch (err) { + return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live } + } + } finally { + console.info = info + } +} + +test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => { + const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`] + for (const token of [undefined, '', 'short']) { + const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys }) + assert.equal(r.status, 404, `token ${JSON.stringify(token)}`) + assert.equal(r.live.size, 1) + } + for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) { + const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys }) + assert.equal(r.status, 404, name) + assert.equal(r.live.size, 1) + } + for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) { + const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys }) + assert.equal(r.status, 401, String(authorization)) + assert.equal(r.live.size, 1) + assert.equal(r.responseHeaders['cache-control'], 'no-store') + } +}) + +test('sitePurge: route purges, answers the count and logs kind and slug only', async () => { + const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json'] + const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys }) + assert.equal(r.status, 200) + assert.deepEqual(r.result, { purged: 2 }) + assert.deepEqual([...r.live], ['nitro:functions:_:x.json']) + assert.equal(r.responseHeaders['cache-control'], 'no-store') + assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)']) + assert.ok(!r.logs.join('').includes(TOKEN)) + + const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] }) + assert.equal(bad.status, 200) + assert.deepEqual(bad.result, { purged: 0 }) + assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )']) +}) + +test('sitePurge: allowProxied lets a proxied request reach the token check', () => { + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized') + assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled') + assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled') + // Without the flag, or with anything but true, a proxied request stays refused. + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied') + assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') +}) + +test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => { + const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`] + const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' } + for (const allowProxied of [true, 'true']) { + const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys }) + assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`) + assert.deepEqual(r.result, { purged: 2 }) + assert.equal(r.live.size, 0) + } + const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys }) + assert.equal(wrong.status, 401) + assert.equal(wrong.live.size, 2) + for (const allowProxied of [false, undefined, 'false', 1]) { + const r = await callRoute({ token: TOKEN, allowProxied, headers, keys }) + assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`) + assert.equal(r.live.size, 2) + } +}) + +test('sitePurge: resolve config prefers a non-empty new token over the deprecated one', () => { + const A = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A }, siteCache: { purgeToken: B } }), { token: A, allowProxied: false, deprecated: [] }) + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A, allowProxied: false }, siteCache: { purgeToken: '' } }), { token: A, allowProxied: false, deprecated: [] }) + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] }) + assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] }) + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: null, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] }) + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: '' } }), { token: '', allowProxied: false, deprecated: [] }) +}) + +test('sitePurge: resolve config turns allowProxied on for either flag, true or "true" only', () => { + for (const value of [true, 'true']) { + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).allowProxied, true, `new ${JSON.stringify(value)}`) + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).deprecated, []) + const old = resolveSitePurgeConfig({ siteCache: { allowProxied: value } }) + assert.equal(old.allowProxied, true, `old ${JSON.stringify(value)}`) + assert.deepEqual(old.deprecated, ['allowProxied']) + // The new flag on: the old one is not counted as in use. + assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }).deprecated, []) + } + for (const value of [false, undefined, 'false', 1, null, 'TRUE']) { + const r = resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }) + assert.equal(r.allowProxied, false, JSON.stringify(value)) + assert.deepEqual(r.deprecated, []) + } + const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B, allowProxied: 'true' } }).deprecated, ['token', 'allowProxied']) +}) + +test('sitePurge: resolve config tolerates a missing or malformed config', () => { + for (const config of [undefined, null, 'x', 42, {}, { cachePurge: 'x', siteCache: 7 }]) { + assert.deepEqual(resolveSitePurgeConfig(config), { token: undefined, allowProxied: false, deprecated: [] }, JSON.stringify(config)) + } +}) + +test('sitePurge: route reads the new token, falls back to the deprecated one, and the new one wins', async () => { + const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`] + const NEW = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) + const r = await callRoute({ cachePurge: { token: TOKEN }, headers: { authorization: `Bearer ${TOKEN}` }, keys }) + assert.equal(r.status, 200) + assert.deepEqual(r.result, { purged: 1 }) + const old = await callRoute({ token: TOKEN, cachePurge: { token: '' }, headers: { authorization: `Bearer ${TOKEN}` }, keys }) + assert.equal(old.status, 200) + const both = { token: TOKEN, cachePurge: { token: NEW } } + assert.equal((await callRoute({ ...both, headers: { authorization: `Bearer ${NEW}` }, keys })).status, 200) + const refused = await callRoute({ ...both, headers: { authorization: `Bearer ${TOKEN}` }, keys }) + assert.equal(refused.status, 401) + assert.equal(refused.live.size, 1) + const short = await callRoute({ cachePurge: { token: 'short' }, headers: { authorization: 'Bearer short' }, keys }) + assert.equal(short.status, 404) +}) + +test('sitePurge: route lets a proxied request reach the token check with cachePurge.allowProxied', async () => { + const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`] + const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9' } + for (const allowProxied of [true, 'true']) { + const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys }) + assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`) + const wrong = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers: { ...headers, authorization: 'Bearer wrong' }, keys }) + assert.equal(wrong.status, 401) + } + // The deprecated flag with the new token still opens it. + assert.equal((await callRoute({ token: '', allowProxied: true, cachePurge: { token: TOKEN }, headers, keys })).status, 200) + for (const allowProxied of [false, undefined, 'false', 1]) { + const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys }) + assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`) + } +}) + +test('sitePurge: redactSiteConfigStack removes the purge keys from every stack entry', () => { + assert.deepEqual([...SITE_CONFIG_PURGE_KEYS], ['cachePurgeToken', 'cacheAllowProxied']) + const stack: Record[] = [ + { url: 'https://acme.test', name: 'Acme', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, + { _context: 'x' }, + ] + assert.equal(redactSiteConfigStack(stack), 2) + assert.deepEqual(stack, [{ url: 'https://acme.test', name: 'Acme' }, { _context: 'x' }]) + assert.equal(redactSiteConfigStack(stack), 0) + const spread = [{ cachePurgeToken: TOKEN }, { cachePurgeToken: TOKEN, indexable: true }] + assert.equal(redactSiteConfigStack(spread), 2) + assert.deepEqual(spread, [{}, { indexable: true }]) + for (const value of [undefined, null, 'stack', 7, {}, { cachePurgeToken: TOKEN }]) { + assert.equal(redactSiteConfigStack(value), 0, JSON.stringify(value)) + } + assert.equal(redactSiteConfigStack([null, undefined, 'x', 3]), 0) +}) + +test('sitePurge: the deprecation message names variables, never a value', () => { + assert.equal(sitePurgeDeprecationMessage([]), '') + const token = sitePurgeDeprecationMessage(['token']) + assert.match(token, /^\[site-cache\] /) + assert.ok(token.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN')) + assert.ok(!token.includes('NUXT_SITE_CACHE_ALLOW_PROXIED')) + assert.match(token, /rotate/) + assert.ok(!token.includes('\n')) + const both = sitePurgeDeprecationMessage(['token', 'allowProxied']) + assert.ok(both.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN')) + assert.ok(both.includes('NUXT_SITE_CACHE_ALLOW_PROXIED -> NUXT_CACHE_PURGE_ALLOW_PROXIED')) + assert.ok(sitePurgeDeprecationMessage(['allowProxied']).includes('NUXT_CACHE_PURGE_ALLOW_PROXIED')) +}) + +type SiteConfigHook = (ctx: unknown) => void + +/** Runs the shared Nitro plugin on a fake nitroApp with the given runtime config. */ +async function runPlugin(config: unknown) { + const g = globalThis as Record + g.defineNitroPlugin = (fn: unknown) => fn + g.useRuntimeConfig = () => config + const hooks: Record = {} + const nitroApp = { + hooks: { + hook(name: string, fn: SiteConfigHook) { + ;(hooks[name] ??= []).push(fn) + }, + }, + } + const warnings: string[] = [] + const warn = console.warn + console.warn = (line: string) => { + warnings.push(line) + } + try { + const plugin = (await import('../server/plugins/site-cache-purge.ts')).default as (app: unknown) => void + plugin(nitroApp) + } finally { + console.warn = warn + } + return { hooks, warnings } +} + +function resetWarned() { + delete (globalThis as Record)[SITE_PURGE_WARNED] +} + +test('sitePurge: plugin strips the purge keys on site-config:init', async () => { + resetWarned() + const { hooks, warnings } = await runPlugin({ cachePurge: { token: TOKEN } }) + assert.equal(hooks['site-config:init']?.length, 1) + assert.deepEqual(warnings, []) + const stack = [{ url: 'https://acme.test', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, { _context: 'x' }] + hooks['site-config:init'][0]({ siteConfig: { stack }, event: {} }) + assert.deepEqual(stack, [{ url: 'https://acme.test' }, { _context: 'x' }]) + for (const ctx of [undefined, null, {}, { siteConfig: {} }, { siteConfig: { stack: 'x' } }]) { + hooks['site-config:init'][0](ctx) + } + resetWarned() +}) + +test('sitePurge: plugin warns once per process about a deprecated name, without the value', async () => { + resetWarned() + const config = { siteCache: { purgeToken: TOKEN, allowProxied: 'true' } } + const first = await runPlugin(config) + assert.equal(first.warnings.length, 1) + assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_TOKEN')) + assert.ok(first.warnings[0].includes('NUXT_SITE_CACHE_PURGE_TOKEN')) + assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_ALLOW_PROXIED')) + assert.ok(!first.warnings[0].includes(TOKEN)) + // Another layer's copy of the plugin in the same process stays quiet. + const second = await runPlugin(config) + assert.deepEqual(second.warnings, []) + assert.equal(second.hooks['site-config:init']?.length, 1) + resetWarned() + + const fresh = await runPlugin({ cachePurge: { token: TOKEN }, siteCache: { purgeToken: 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) } }) + assert.deepEqual(fresh.warnings, [], 'the new name wins: no warning') + const none = await runPlugin({}) + assert.deepEqual(none.warnings, []) + resetWarned() +}) + +test('sitePurge: an authorized purge clears the resolve cache, a refused one does not', async () => { + const { resolveCached, seoCacheSizes } = await import('../server/lib/resolve.ts') + const g = globalThis as Record + const fill = async () => { + g.useRuntimeConfig = () => ({ seo: { apiBase: 'http://backend.test/_seo/api/v1' }, public: { seo: { enabled: true } } }) + g.$fetch = async () => ({ action: 'ok', status: 200, url: 'https://acme.test/x', locale: 'en', staging: false, seo: null }) + const answer = await resolveCached({ context: {} } as never, '/x') + assert.equal(answer?.action, 'ok') + assert.equal(seoCacheSizes().resolve, 1) + } + await fill() + const refused = await callRoute({ token: TOKEN, headers: { authorization: 'Bearer wrong' }, keys: [] }) + assert.equal(refused.status, 401) + assert.equal(seoCacheSizes().resolve, 1) + await fill() + const ok = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, keys: [] }) + assert.equal(ok.status, 200) + assert.equal(seoCacheSizes().resolve, 0) +}) + +test('sitePurge: the purge hook clears the SEO caches after a 200 purge answered by any layer', async () => { + const { resolveCached, seoCacheSizes } = await import('../server/lib/resolve.ts') + const g = globalThis as Record + let hook: ((event: unknown) => void) | undefined + g.defineNitroPlugin = (fn: unknown) => fn + const plugin = (await import('../server/plugins/seo-cache-purge.ts')).default as unknown as (app: unknown) => void + plugin({ hooks: { hook: (_name: string, fn: (event: unknown) => void) => (hook = fn) } }) + assert.ok(hook) + const fill = async () => { + g.useRuntimeConfig = () => ({ seo: {}, public: { seo: {} } }) + g.$fetch = async () => ({ action: 'not_found', status: 404, url: '/y', locale: '', staging: false, seo: null }) + await resolveCached({ context: {} } as never, '/y') + } + const event = (method: string, path: string, statusCode: number) => ({ method, path, node: { res: { statusCode } } }) + await fill() + hook(event('POST', '/api/__site__/cache/purge', 401)) + hook(event('GET', '/api/__site__/cache/purge', 200)) + hook(event('POST', '/blog', 200)) + assert.equal(seoCacheSizes().resolve, 1) + hook(event('POST', '/api/__site__/cache/purge', 200)) + assert.equal(seoCacheSizes().resolve, 0) + await fill() + hook(event('POST', '/api/__journal__/cache/purge?x=1', 200)) + assert.equal(seoCacheSizes().resolve, 0) +})