Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f23340a2f | ||
|
|
a229f6a6c5 | ||
|
|
96b6bc093e |
19
README.md
19
README.md
@@ -59,7 +59,10 @@ export default defineNuxtConfig({
|
||||
| `runtimeConfig.faq.apiBase` | `http://127.0.0.1:8096/_faq/api/v1` | `NUXT_FAQ_API_BASE` (server only, used for SSR) |
|
||||
| `runtimeConfig.public.faq.apiBase` | `/_faq/api/v1` | `NUXT_PUBLIC_FAQ_API_BASE` (browser, same origin) |
|
||||
|
||||
| `runtimeConfig.siteCache.purgeToken` | `''` (route off) | `NUXT_SITE_CACHE_PURGE_TOKEN` (server only; at least 32 characters) |
|
||||
| `runtimeConfig.cachePurge.token` | `''` (route off) | `NUXT_CACHE_PURGE_TOKEN` (server only; at least 32 characters) |
|
||||
| `runtimeConfig.cachePurge.allowProxied` | `false` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` (server only) |
|
||||
| `runtimeConfig.siteCache.purgeToken` | `''` | Deprecated (one release): `NUXT_SITE_CACHE_PURGE_TOKEN`, the earlier name of `cachePurge.token`, read when that is empty |
|
||||
| `runtimeConfig.siteCache.allowProxied` | `false` | Deprecated (one release): `NUXT_SITE_CACHE_ALLOW_PROXIED`, the earlier name of `cachePurge.allowProxied` |
|
||||
|
||||
The base URLs always come from runtime config, never from request input. In production the web server routes `/_faq/` to the SummerCMS binary.
|
||||
|
||||
@@ -69,16 +72,24 @@ A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300
|
||||
|
||||
```
|
||||
POST /api/__site__/cache/purge
|
||||
Authorization: Bearer <NUXT_SITE_CACHE_PURGE_TOKEN>
|
||||
Authorization: Bearer <NUXT_CACHE_PURGE_TOKEN>
|
||||
{"kind":"faq","slug":"shipping"}
|
||||
→ {"purged": <n>}
|
||||
```
|
||||
|
||||
- It removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and logs `[site-cache] purge: …` with the kind and slug.
|
||||
- Set `NUXT_SITE_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:<port>/api/__site__/cache/purge`. Without a token of that length the route answers 404.
|
||||
- Set `NUXT_CACHE_PURGE_TOKEN` to the backend's `site_cache.purge_token` (at least 32 characters) and point the backend's `site_cache.purge_url` at `http://127.0.0.1:<port>/api/__site__/cache/purge`. Without a token of that length the route answers 404.
|
||||
- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), so only the backend on the same machine reaches it, and 401 to a missing or wrong bearer token (compared in constant time).
|
||||
- Behind a TLS-terminating proxy, for a backend that reaches the site at an https `site_cache.purge_url` on another host, the proxy adds those headers. Set `NUXT_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.allowProxied`, default `false`) and the route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret. Unset, proxied requests stay refused.
|
||||
- The layer adds the route rule `'/api/__site__/**': { cache: false }`; without it, a site-wide `swr` rule would answer the purge from the cache.
|
||||
- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts` and `server/api/__site__/cache/purge.post.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
|
||||
- vue-journal-layer, vue-menu-layer and vue-faq-layer ship the same `server/lib/siteCachePurge.ts`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.ts` byte for byte (a test pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
|
||||
- Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config (in a site that also uses `@nuxtjs/seo`, for example through vue-journal-layer) copies every such variable into the site config, which is published in every page's payload. The Nitro plugin `server/plugins/site-cache-purge.ts` removes `cachePurgeToken` and `cacheAllowProxied` from that site config on its `site-config:init` hook, whatever names the site uses, and logs one `[site-cache]` deprecation warning per process when a setting comes from an old name (variable names only, never a value). Without nuxt-site-config the plugin does nothing.
|
||||
|
||||
### Upgrading from 0.3
|
||||
|
||||
- Rename `NUXT_SITE_CACHE_PURGE_TOKEN` to `NUXT_CACHE_PURGE_TOKEN` and `NUXT_SITE_CACHE_ALLOW_PROXIED` to `NUXT_CACHE_PURGE_ALLOW_PROXIED` in the site's environment. The old names keep working through 0.4.x (a non-empty new token wins) and are removed in the next minor release.
|
||||
- Why: nuxt-site-config publishes every `NUXT_SITE_*` variable in the page payload, so in a site with nuxt-site-config a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.4 the layer strips both keys from the site config even under the old names.
|
||||
- Rotate a token that was ever set under `NUXT_SITE_CACHE_PURGE_TOKEN`: earlier versions published it. Set the new value in the site's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`, then restart both.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -23,12 +23,30 @@ export default defineNuxtConfig({
|
||||
// from request input.
|
||||
apiBase: 'http://127.0.0.1:8096/_faq/api/v1',
|
||||
},
|
||||
siteCache: {
|
||||
cachePurge: {
|
||||
// Server-only bearer token of the shared site cache purge route
|
||||
// POST /api/__site__/cache/purge (NUXT_SITE_CACHE_PURGE_TOKEN), the
|
||||
// POST /api/__site__/cache/purge (NUXT_CACHE_PURGE_TOKEN), the
|
||||
// backend's site_cache.purge_token. Shorter than 32 characters, the
|
||||
// route answers 404.
|
||||
// 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: {
|
||||
faq: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-faq-layer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
27
server/api/__site__/cache/purge.post.ts
vendored
27
server/api/__site__/cache/purge.post.ts
vendored
@@ -1,29 +1,38 @@
|
||||
// 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.siteCache.purgeToken>` and the body
|
||||
// {"kind":"<kind>","slug":"<slug>"}; the answer is {"purged":<n>}. The kind
|
||||
// and slug only reach the log line.
|
||||
// `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);
|
||||
// 401 for a missing or wrong bearer token. The '/api/__site__/**' route rule
|
||||
// keeps this route out of the route cache.
|
||||
// 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).
|
||||
//
|
||||
// Shared: several layers ship this file byte for byte, and Nitro keeps one
|
||||
// handler per route and method.
|
||||
|
||||
import { authorizeSitePurge, parseSitePurgeEvent, purgeSiteRouteCache } from '../../../lib/siteCachePurge.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 config = useRuntimeConfig(event)
|
||||
const { token, allowProxied } = resolveSitePurgeConfig(useRuntimeConfig(event))
|
||||
const verdict = authorizeSitePurge({
|
||||
token: (config.siteCache as { purgeToken?: unknown } | undefined)?.purgeToken,
|
||||
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' })
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Shared site cache purge: the authorization rules of the purge route
|
||||
// POST /api/__site__/cache/purge, the purge of Nitro's route cache and the
|
||||
// parsing of the purge request body. Pure TypeScript outside Nitro's and
|
||||
// Nuxt's auto-import directories, so several layers can ship this file
|
||||
// byte for byte, and `node --test` and the route import it directly.
|
||||
// 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'
|
||||
|
||||
@@ -21,12 +24,19 @@ const KIND_PATTERN = /^[a-z_]{1,40}$/
|
||||
export type SitePurgeVerdict = 'ok' | 'disabled' | 'proxied' | 'unauthorized'
|
||||
|
||||
export interface SitePurgeAuthInput {
|
||||
/** The configured token (runtimeConfig.siteCache.purgeToken). */
|
||||
/** 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 {
|
||||
@@ -36,13 +46,14 @@ function digest(value: string): Buffer {
|
||||
/**
|
||||
* 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), `ok` when
|
||||
* the Bearer value equals the token (compared in constant time over sha256
|
||||
* digests) and `unauthorized` otherwise.
|
||||
* 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 }: SitePurgeAuthInput): SitePurgeVerdict {
|
||||
export function authorizeSitePurge({ token, authorization, proxied, allowProxied }: SitePurgeAuthInput): SitePurgeVerdict {
|
||||
if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled'
|
||||
if (proxied) return 'proxied'
|
||||
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'
|
||||
@@ -89,3 +100,109 @@ export function parseSitePurgeEvent(body: unknown): SitePurgeEvent {
|
||||
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.'
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,30 @@
|
||||
// Shared site cache purge: route authorization, the route-cache purge, the
|
||||
// purge body parsing and the byte-identity of the files every layer ships.
|
||||
// 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 two shared files. vue-journal-layer, vue-menu-layer and
|
||||
// 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 = '7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8'
|
||||
const SHARED_ROUTE_SHA256 = 'c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06'
|
||||
const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a'
|
||||
const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93'
|
||||
const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6'
|
||||
|
||||
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
|
||||
@@ -27,6 +35,7 @@ function sha256(relative: string): string {
|
||||
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', () => {
|
||||
@@ -95,3 +104,335 @@ test('sitePurge: purge event kinds and slugs', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user