feat: shared site cache purge route clears the SEO caches
- 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
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
})
|
||||
|
||||
55
server/api/__site__/cache/purge.post.ts
vendored
Normal file
55
server/api/__site__/cache/purge.post.ts
vendored
Normal file
@@ -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 <runtimeConfig.cachePurge.token>`
|
||||
// (NUXT_CACHE_PURGE_TOKEN) and the body {"kind":"<kind>","slug":"<slug>"};
|
||||
// the answer is {"purged":<n>}. 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 }
|
||||
})
|
||||
@@ -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<string, string> {
|
||||
const recorded = event.context.seoForwardedFor as string | undefined
|
||||
|
||||
208
server/lib/siteCachePurge.ts
Normal file
208
server/lib/siteCachePurge.ts
Normal file
@@ -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[]> | string[]
|
||||
removeItem(key: string): Promise<unknown> | 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<number> {
|
||||
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<string, unknown>
|
||||
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<string, unknown> {
|
||||
if (config === null || typeof config !== 'object') return {}
|
||||
const value = (config as Record<string, unknown>)[name]
|
||||
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
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<string, unknown>)[key]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
const DEPRECATED_NAMES: Record<SitePurgeDeprecated, [string, string]> = {
|
||||
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.'
|
||||
)
|
||||
}
|
||||
22
server/plugins/seo-cache-purge.ts
Normal file
22
server/plugins/seo-cache-purge.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
48
server/plugins/site-cache-purge.ts
Normal file
48
server/plugins/site-cache-purge.ts
Normal file
@@ -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<symbol, unknown>
|
||||
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)
|
||||
})
|
||||
})
|
||||
216
testing/purge-check.mjs
Normal file
216
testing/purge-check.mjs
Normal file
@@ -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 <dir> --path <page path>
|
||||
//
|
||||
// The CMS change is a new title in the fixture resolve answer for the path
|
||||
// (<fixtures>/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 (<n> 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<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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>} [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<string, string>} 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<void> } | 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<void> }} */ (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)
|
||||
})
|
||||
194
testing/purge-token-check.mjs
Normal file
194
testing/purge-token-check.mjs
Normal file
@@ -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 <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)
|
||||
})
|
||||
485
tests/sitePurge.test.ts
Normal file
485
tests/sitePurge.test.ts
Normal file
@@ -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<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()
|
||||
})
|
||||
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user