Files
vue-menu-layer/tests/sitePurge.test.ts
Jakub Zych ef3a090647 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

439 lines
23 KiB
TypeScript

// 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 and the byte-identity of the files
// every layer ships.
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. vue-journal-layer, vue-menu-layer and
// vue-faq-layer ship them byte for byte; a change lands in all three with
// new values here.
const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a'
const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93'
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<string, string>
body?: unknown
bodyError?: boolean
keys?: string[]
}) {
const responseHeaders: Record<string, string> = {}
const logs: string[] = []
const live = new Set(opts.keys ?? [])
const g = globalThis as Record<string, unknown>
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<unknown>
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<string, unknown>[] = [
{ 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<string, unknown>
g.defineNitroPlugin = (fn: unknown) => fn
g.useRuntimeConfig = () => config
const hooks: Record<string, SiteConfigHook[]> = {}
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<symbol, unknown>)[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()
})