Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0cc87118c | ||
|
|
f5d7aacf11 | ||
|
|
5936f6611d | ||
|
|
3e0b37756e |
41
README.md
41
README.md
@@ -3,7 +3,7 @@
|
||||
A Nuxt 4 layer that renders a Journal blog (post list, post, category, tag and author pages, RSS link, SEO and sitemap) from the Journal API of a SummerCMS application.
|
||||
|
||||
```bash
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0
|
||||
pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.4.0
|
||||
```
|
||||
|
||||
## Overview
|
||||
@@ -108,7 +108,11 @@ Runtime config, overridable by environment variables at start:
|
||||
|-----|-----|---------|---------|
|
||||
| `runtimeConfig.journal.apiBase` | `NUXT_JOURNAL_API_BASE` | `http://127.0.0.1:8096/_journal/api/v1` | Server-side API base for SSR, the sitemap and RSS. Use the internal address of the SummerCMS application, not its public URL. |
|
||||
| `runtimeConfig.public.journal.apiBase` | `NUXT_PUBLIC_JOURNAL_API_BASE` | `/_journal/api/v1` | Browser API base for client-side navigation; the reverse proxy routes it to the application. |
|
||||
| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the [site cache purge](#site-cache-purge) route. With fewer than 32 characters the route answers 404. |
|
||||
| `runtimeConfig.cachePurge.token` | `NUXT_CACHE_PURGE_TOKEN` | empty | Server-only bearer token of the shared [site cache purge](#site-cache-purge) route `/api/__site__/cache/purge`. With fewer than 32 characters the route answers 404. |
|
||||
| `runtimeConfig.cachePurge.allowProxied` | `NUXT_CACHE_PURGE_ALLOW_PROXIED` | `false` | `true` lets the shared purge route accept requests that came through a reverse proxy, for a backend that reaches the site at an https URL on another host; the bearer token is then the only gate. |
|
||||
| `runtimeConfig.siteCache.purgeToken` | `NUXT_SITE_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): the earlier name of `cachePurge.token`, read when that is empty. See [Upgrading from 0.5](#upgrading-from-05). |
|
||||
| `runtimeConfig.siteCache.allowProxied` | `NUXT_SITE_CACHE_ALLOW_PROXIED` | `false` | Deprecated (one release): the earlier name of `cachePurge.allowProxied`; either flag set to `true` turns it on. |
|
||||
| `runtimeConfig.journal.cachePurgeToken` | `NUXT_JOURNAL_CACHE_PURGE_TOKEN` | empty | Deprecated (one release): token of the old route `/api/__journal__/cache/purge`. With fewer than 32 characters that route answers 404. |
|
||||
|
||||
The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`.
|
||||
|
||||
@@ -116,17 +120,19 @@ Server-side requests forward the visitor's `x-forwarded-for` and append the addr
|
||||
|
||||
## Site cache purge
|
||||
|
||||
An app that caches its pages with Nitro route rules (`swr` or `cache`) would otherwise serve a page rendered before a Journal edit until the cache entry expires. The layer adds a purge route that the SummerCMS Journal plugin calls after an admin save of a post or category commits (its `golem15.journal.site_cache.purge_url` and `purge_token` settings), so the edit shows on the next page view.
|
||||
An app that caches its pages with Nitro route rules (`swr` or `cache`) would otherwise serve a page rendered before an admin edit until the cache entry expires. The layer adds the shared purge route that the SummerCMS backend calls after an admin save commits (the framework's `site_cache.purge_url` and `site_cache.purge_token` settings), so the edit shows on the next page view. The Journal plugin purges after post and category saves, the menu plugin after menu saves and reorders, the FAQ plugin after FAQ saves.
|
||||
|
||||
**Request.** `POST /api/__journal__/cache/purge` with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post"|"category","slug":"<slug>"}`. The answer is `200` with `{"purged":<n>}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[journal] cache purge: removed <n> route cache entries (<kind> <slug>)`; the slug loses control characters and is cut to 200 characters first.
|
||||
**Shared route.** `POST /api/__site__/cache/purge` with `Authorization: Bearer <runtimeConfig.cachePurge.token>` (`NUXT_CACHE_PURGE_TOKEN`), `Content-Type: application/json` and the body `{"kind":"<kind>","slug":"<slug>"}` (kinds such as `post`, `category`, `menu`, `menu_entry`, `faq`). The answer is `200` with `{"purged":<n>}`, the number of cache entries removed, and `cache-control: no-store`. The route logs one line, `[site-cache] purge: removed <n> route cache entries (<kind> <slug>)`; a kind other than lower-case letters and underscores (up to 40) logs as `unknown`, and the slug loses control characters and is cut to 200 characters first. vue-journal-layer, vue-menu-layer and vue-faq-layer ship `server/lib/siteCachePurge.ts`, `server/api/__site__/cache/purge.post.ts` and `server/plugins/site-cache-purge.ts` byte for byte (`tests/sitePurge.test.ts` pins their sha256), so a site that extends several of them gets one route, whichever copy Nitro keeps.
|
||||
|
||||
**Guards.**
|
||||
**Old route (one release).** `POST /api/__journal__/cache/purge` with the token in `runtimeConfig.journal.cachePurgeToken` (`NUXT_JOURNAL_CACHE_PURGE_TOKEN`) and the body `{"kind":"post"|"category","slug":"<slug>"}` keeps working unchanged, with the log line `[journal] cache purge: …`, so a backend still configured with the Journal plugin's deprecated keys keeps purging. Move to the shared route and `NUXT_CACHE_PURGE_TOKEN` before the next release removes it.
|
||||
|
||||
**Guards** (both routes).
|
||||
|
||||
- POST only.
|
||||
- `404` when `runtimeConfig.journal.cachePurgeToken` holds fewer than 32 characters, so an app without a token has no purge route.
|
||||
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy.
|
||||
- `404` when the route's token holds fewer than 32 characters, so an app without a token has no purge route.
|
||||
- `404` for any request that carries `X-Forwarded-For`, `X-Real-IP` or `Forwarded`: the route is for a caller on the same host, never for traffic through the reverse proxy. The shared route makes one exception, below.
|
||||
- `401` for a missing or wrong bearer token. The comparison runs in constant time over sha256 digests.
|
||||
- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache.
|
||||
- The layer's `routeRules` set `'/api/__journal__/**': { cache: false }` and `'/api/__site__/**': { cache: false }`. Nitro wraps every handler that matches an app rule such as `'/**': { swr }` in its cached handler, which keys by path, does not skip POST and hides the request headers from the handler, so without this rule the route would answer from the cache.
|
||||
|
||||
**What it removes.** Every entry of Nitro's route cache (the `nitro:routes` group of the `cache` storage): pages, their `_payload.json` and feeds. Other cache groups stay. One save can change many pages (the menu lists every category on every page, and the home, category, author and related-post lists show posts), and Nitro keys entries by a hash of the path, so a full purge is the reliable choice; it costs one fresh render per page on its next view.
|
||||
|
||||
@@ -134,11 +140,20 @@ An app that caches its pages with Nitro route rules (`swr` or `cache`) would oth
|
||||
|
||||
**Deployment.**
|
||||
|
||||
- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_JOURNAL_CACHE_PURGE_TOKEN` and in the backend's purge token setting. Keep it out of committed files.
|
||||
- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__journal__/cache/purge`), not through the public site.
|
||||
- Generate the token on the server, at least 32 characters, and set the same value in the Nuxt app's `NUXT_CACHE_PURGE_TOKEN` and in the backend's `site_cache.purge_token`. Keep it out of committed files. Never put a secret in a `NUXT_SITE_*` or `NUXT_PUBLIC_SITE_*` variable: nuxt-site-config publishes those in every page's payload.
|
||||
- The backend calls the route on the Nuxt server's loopback address (for example `http://127.0.0.1:3000/api/__site__/cache/purge`), not through the public site.
|
||||
- A backend on another host calls an https `site_cache.purge_url` through the site's TLS-terminating proxy, which adds forwarding headers. Set `NUXT_CACHE_PURGE_ALLOW_PROXIED=true` (`runtimeConfig.cachePurge.allowProxied`) and the shared route accepts proxied requests; the bearer token is then the only gate, so keep it long and secret, and let the proxy forward POST to this one path only. Unset, proxied requests stay refused. The old `/api/__journal__/cache/purge` route never accepts them.
|
||||
- The reverse proxy must not forward POST to Nuxt (for example nginx `limit_except GET HEAD { deny all; }`), so the route stays off the public path even before its own checks.
|
||||
- The purge clears the cache storage of the Nuxt process that answers. A server with several Nuxt processes needs a shared storage driver for the `cache` mount, or every process keeps its own entries.
|
||||
|
||||
**Site config stripping.** nuxt-site-config (through `@nuxtjs/seo`) copies every `NUXT_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 layer's Nitro plugin `server/plugins/site-cache-purge.ts` removes `cachePurgeToken` and `cacheAllowProxied` from the site config on nuxt-site-config's `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 (it names the variables, never a value). In an app without nuxt-site-config the plugin does nothing.
|
||||
|
||||
### Upgrading from 0.5
|
||||
|
||||
- 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.6.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 up to 0.5 a token set under `NUXT_SITE_CACHE_PURGE_TOKEN` appeared in every page as `cachePurgeToken`. From 0.6 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.
|
||||
|
||||
## Theming
|
||||
|
||||
Every visual value is a token in `app/assets/css/journal-tokens.css`:
|
||||
@@ -190,16 +205,18 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
|
||||
|
||||
## Testing
|
||||
|
||||
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts`: authorization, the route-cache purge, the purge body and browser revalidation), and pl/en key and placeholder parity. They need no build, network or database.
|
||||
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher, pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the embed expansion and its sanitize-first order, the post page view, sitemap entries, SEO values, the site cache purge rules (`tests/siteCache.test.ts` for the old route's helpers and browser revalidation, `tests/sitePurge.test.ts` for the shared route's helpers, the purge settings under their new and deprecated names, the site config stripping plugin and the sha256 pins of the shared files), and pl/en key and placeholder parity. They need no build, network or database.
|
||||
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
|
||||
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file>` boots a fixture Journal API and the built server and checks each line of the expectations file (`STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]`). It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`.
|
||||
- `testing/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>` proves the [site cache purge](#site-cache-purge) on a built server whose route rules cache pages: it caches the page, changes the entity in a temporary copy of the fixtures, requires the cached page to stay unchanged, purges with a random token, and requires the change on the next request. It also checks `max-age=0` on the page and its 304, the purged `_payload.json`, a second purge after re-caching, `401` without or with a wrong token, `404` behind a proxy and `404` on a server without a token. The last line is `purge-check: ok (<n> checks)`.
|
||||
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`, and `reload()` re-reads them; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server. Apps can import both from `node_modules/vue-journal-layer/testing/`.
|
||||
- `testing/purge-token-check.mjs --output <.output> --fixtures <dir> --path <page path>` proves on a built server that the purge token never reaches a page. It starts the server three times with fresh random tokens: under `NUXT_CACHE_PURGE_TOKEN`, under the deprecated `NUXT_SITE_CACHE_PURGE_TOKEN` with `NUXT_SITE_CACHE_ALLOW_PROXIED=true`, and under both names with different values. Each time `/`, the page and its `_payload.json` (when served) must contain no token value and neither `cachePurgeToken` nor `cacheAllowProxied`, the shared purge route must accept the expected bearer and refuse the others, and the deprecation warning must appear once with the old names only. The last line is `purge-token-check: ok (<n> checks)`.
|
||||
- `testing/fixture-api.mjs` (`startFixtureApi`) serves the anonymous Journal routes from JSON fixtures in `testing/fixtures/acme/`, and `reload()` re-reads them; `testing/nuxt-server.mjs` (`startNuxtServer`) starts a built server and returns its `url`, `close()` and `output()`, the server's captured stdout and stderr so far. Apps can import both from `node_modules/vue-journal-layer/testing/`.
|
||||
|
||||
```bash
|
||||
pnpm build:playground
|
||||
node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt
|
||||
node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
|
||||
node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --path /blog/hello-acme
|
||||
```
|
||||
|
||||
The playground caches every page with `routeRules: { '/**': { swr: 300 } }`, like a real host.
|
||||
|
||||
@@ -29,11 +29,37 @@ export default defineNuxtConfig({
|
||||
// Server-only base for SSR fetches (NUXT_JOURNAL_API_BASE). It never
|
||||
// comes from request input.
|
||||
apiBase: 'http://127.0.0.1:8096/_journal/api/v1',
|
||||
// Server-only bearer token of the site cache purge route
|
||||
// (NUXT_JOURNAL_CACHE_PURGE_TOKEN). Shorter than 32 characters, the
|
||||
// route answers 404.
|
||||
// Server-only bearer token of the old Journal purge route
|
||||
// POST /api/__journal__/cache/purge (NUXT_JOURNAL_CACHE_PURGE_TOKEN),
|
||||
// kept for one release; new sites use cachePurge.token. Shorter
|
||||
// than 32 characters, the route answers 404.
|
||||
cachePurgeToken: '',
|
||||
},
|
||||
cachePurge: {
|
||||
// Server-only bearer token of the shared site cache purge route
|
||||
// POST /api/__site__/cache/purge (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: {
|
||||
journal: {
|
||||
// Same-origin base for browser fetches (NUXT_PUBLIC_JOURNAL_API_BASE).
|
||||
@@ -42,12 +68,14 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// The Journal's internal 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.
|
||||
// The Journal's internal 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 routes would answer from the
|
||||
// cache without running.
|
||||
routeRules: {
|
||||
'/api/__journal__/**': { cache: false },
|
||||
'/api/__site__/**': { cache: false },
|
||||
},
|
||||
|
||||
// No OG image rendering at runtime.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-journal-layer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
48
server/api/__site__/cache/purge.post.ts
vendored
Normal file
48
server/api/__site__/cache/purge.post.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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).
|
||||
//
|
||||
// Shared: several layers ship this file byte for byte, and Nitro keeps one
|
||||
// handler per route and method.
|
||||
|
||||
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'))
|
||||
console.info(`[site-cache] purge: removed ${purged} route cache entries (${kind} ${slug})`)
|
||||
return { purged }
|
||||
})
|
||||
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.'
|
||||
)
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -20,8 +20,11 @@ function freePort() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The returned `output()` gives the server's captured stdout and stderr so
|
||||
* far, for checks on what the server logged.
|
||||
*
|
||||
* @param {{ outputDir: string, apiBase: string, port?: number, env?: Record<string, string>, timeoutMs?: number }} opts
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
||||
* @returns {Promise<{ url: string, close: () => Promise<void>, output: () => string }>}
|
||||
*/
|
||||
export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) {
|
||||
const entry = join(resolve(outputDir), 'server', 'index.mjs')
|
||||
@@ -61,7 +64,7 @@ export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {},
|
||||
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
|
||||
try {
|
||||
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close }
|
||||
return { url, close, output: () => log }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
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}/_journal/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)
|
||||
})
|
||||
438
tests/sitePurge.test.ts
Normal file
438
tests/sitePurge.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
// Shared site cache purge: route authorization, the route-cache purge, the
|
||||
// purge body parsing, the purge settings under their new and deprecated
|
||||
// names, the site config stripping plugin and the byte-identity of the files
|
||||
// every layer ships.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
MIN_SITE_PURGE_TOKEN_LENGTH,
|
||||
SITE_CONFIG_PURGE_KEYS,
|
||||
SITE_PURGE_WARNED,
|
||||
SITE_ROUTE_CACHE_BASE,
|
||||
authorizeSitePurge,
|
||||
parseSitePurgeEvent,
|
||||
purgeSiteRouteCache,
|
||||
redactSiteConfigStack,
|
||||
resolveSitePurgeConfig,
|
||||
sitePurgeDeprecationMessage,
|
||||
} from '../server/lib/siteCachePurge.ts'
|
||||
|
||||
// The sha256 of the three shared files. vue-journal-layer, vue-menu-layer and
|
||||
// vue-faq-layer ship them byte for byte; a change lands in all three with
|
||||
// new values here.
|
||||
const SHARED_LIB_SHA256 = 'da5e588b94de6a07f99f203d51cc953f497af886d0fad03709d67064a7b09b2a'
|
||||
const SHARED_ROUTE_SHA256 = 'b52189cd8b48586ebeade602d2e23951a8b8156fcc319974a5b3b09f1b409d93'
|
||||
const SHARED_PLUGIN_SHA256 = 'bd4e00888d3121ae9b07cf4f8e130dd54a4fc575ca0b42771d744c36949633a6'
|
||||
|
||||
const TOKEN = 'a'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
|
||||
function sha256(relative: string): string {
|
||||
return createHash('sha256').update(readFileSync(new URL(relative, import.meta.url))).digest('hex')
|
||||
}
|
||||
|
||||
test('sitePurge: the shared files match the pinned hashes', () => {
|
||||
assert.equal(sha256('../server/lib/siteCachePurge.ts'), SHARED_LIB_SHA256, 'server/lib/siteCachePurge.ts')
|
||||
assert.equal(sha256('../server/api/__site__/cache/purge.post.ts'), SHARED_ROUTE_SHA256, 'server/api/__site__/cache/purge.post.ts')
|
||||
assert.equal(sha256('../server/plugins/site-cache-purge.ts'), SHARED_PLUGIN_SHA256, 'server/plugins/site-cache-purge.ts')
|
||||
})
|
||||
|
||||
test('sitePurge: a missing or short token disables the route', () => {
|
||||
for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) {
|
||||
assert.equal(authorizeSitePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`)
|
||||
}
|
||||
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled')
|
||||
assert.equal(MIN_SITE_PURGE_TOKEN_LENGTH, 32)
|
||||
})
|
||||
|
||||
test('sitePurge: a proxied request is refused before the token is checked', () => {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied')
|
||||
})
|
||||
|
||||
test('sitePurge: authorize ok with the token and unauthorized otherwise', () => {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok')
|
||||
for (const authorization of [undefined, null, '', 'Bearer', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`)
|
||||
}
|
||||
})
|
||||
|
||||
/** A fake unstorage prefix storage over a plain key list. */
|
||||
function fakeStorage(keys: string[]) {
|
||||
const live = new Set(keys)
|
||||
const removed: string[] = []
|
||||
return {
|
||||
removed,
|
||||
live,
|
||||
async getKeys(base?: string) {
|
||||
return [...live].filter((k) => !base || k === base || k.startsWith(base + ':'))
|
||||
},
|
||||
async removeItem(key: string) {
|
||||
live.delete(key)
|
||||
removed.push(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('sitePurge: purge removes only nitro:routes: keys', async () => {
|
||||
const routes = [
|
||||
`${SITE_ROUTE_CACHE_BASE}:_:faq.abc123.json`,
|
||||
`${SITE_ROUTE_CACHE_BASE}:_:faq_payloadjson.def456.json`,
|
||||
`${SITE_ROUTE_CACHE_BASE}:_:index.0a1b2c.json`,
|
||||
]
|
||||
const others = ['nitro:functions:_:faq.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json']
|
||||
const storage = fakeStorage([...routes, ...others])
|
||||
assert.equal(SITE_ROUTE_CACHE_BASE, 'nitro:routes')
|
||||
assert.equal(await purgeSiteRouteCache(storage), routes.length)
|
||||
assert.deepEqual([...storage.removed].sort(), [...routes].sort())
|
||||
assert.deepEqual([...storage.live].sort(), [...others].sort())
|
||||
assert.equal(await purgeSiteRouteCache(storage), 0)
|
||||
})
|
||||
|
||||
test('sitePurge: purge event kinds and slugs', () => {
|
||||
for (const kind of ['faq', 'menu', 'menu_entry', 'post', 'category']) {
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind, slug: 'x' })
|
||||
}
|
||||
for (const kind of ['FAQ', 'menu-entry', 'a'.repeat(41), '', 7, null]) {
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind, slug: 'x' }), { kind: 'unknown', slug: 'x' }, JSON.stringify(kind))
|
||||
}
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 'a\nb\r\tc\u0000d\u007fe\u0085f' }), { kind: 'faq', slug: 'abcdef' })
|
||||
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'x'.repeat(500) }).slug.length, 200)
|
||||
for (const body of [null, undefined, 'faq', 42, ['faq', 'x']]) {
|
||||
assert.deepEqual(parseSitePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body))
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: ok in constant time over sha256 digests, whatever the bearer length', () => {
|
||||
// timingSafeEqual on raw strings throws on a length mismatch; over digests
|
||||
// every length compares in constant time and answers unauthorized.
|
||||
for (const length of [1, 31, 33, 64, 4096]) {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${'a'.repeat(length)}`, proxied: false }), 'unauthorized', `length ${length}`)
|
||||
}
|
||||
const long = 'k'.repeat(200)
|
||||
assert.equal(authorizeSitePurge({ token: long, authorization: `Bearer ${long}`, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer\t${TOKEN}\t`, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `BEARER ${TOKEN} `, proxied: false }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: ` Bearer ${TOKEN}`, proxied: false }), 'unauthorized')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}\n`, proxied: false }), 'unauthorized')
|
||||
})
|
||||
|
||||
test('sitePurge: purge works with a synchronous storage and skips the bare base key', async () => {
|
||||
const live = new Set([`${SITE_ROUTE_CACHE_BASE}:_:a.json`, SITE_ROUTE_CACHE_BASE, 'nitro:other:_:b.json'])
|
||||
const removed: string[] = []
|
||||
const storage = {
|
||||
getKeys: (base?: string) => [...live].filter((k) => !base || k === base || k.startsWith(base + ':')),
|
||||
removeItem: (key: string) => {
|
||||
live.delete(key)
|
||||
removed.push(key)
|
||||
},
|
||||
}
|
||||
assert.equal(await purgeSiteRouteCache(storage), 1)
|
||||
assert.deepEqual(removed, [`${SITE_ROUTE_CACHE_BASE}:_:a.json`])
|
||||
assert.ok(live.has(SITE_ROUTE_CACHE_BASE))
|
||||
assert.equal(await purgeSiteRouteCache({ getKeys: () => [], removeItem: () => assert.fail('nothing to remove') }), 0)
|
||||
})
|
||||
|
||||
test('sitePurge: purge event edge cases', () => {
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'a'.repeat(40), slug: '' }), { kind: 'a'.repeat(40), slug: '' })
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq' }), { kind: 'faq', slug: '' })
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'faq', slug: 42 }), { kind: 'faq', slug: '' })
|
||||
assert.deepEqual(parseSitePurgeEvent({ kind: 'post', slug: 'x', extra: 'ignored' }), { kind: 'post', slug: 'x' })
|
||||
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug: 'y'.repeat(200) }).slug.length, 200)
|
||||
// Control characters are stripped before the cut.
|
||||
const slug = '\u0001'.repeat(50) + 'z'.repeat(200)
|
||||
assert.equal(parseSitePurgeEvent({ kind: 'faq', slug }).slug, 'z'.repeat(200))
|
||||
assert.deepEqual(parseSitePurgeEvent({}), { kind: 'unknown', slug: '' })
|
||||
})
|
||||
|
||||
/** One call of the shared route with Nitro's auto-imports stubbed. */
|
||||
async function callRoute(opts: {
|
||||
token?: unknown
|
||||
allowProxied?: unknown
|
||||
cachePurge?: { token?: unknown; allowProxied?: unknown }
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
bodyError?: boolean
|
||||
keys?: string[]
|
||||
}) {
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
const logs: string[] = []
|
||||
const live = new Set(opts.keys ?? [])
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.defineEventHandler = (fn: unknown) => fn
|
||||
g.setResponseHeader = (_e: unknown, name: string, value: string) => {
|
||||
responseHeaders[name] = value
|
||||
}
|
||||
g.useRuntimeConfig = () => ({
|
||||
siteCache: opts.token === undefined ? undefined : { purgeToken: opts.token, allowProxied: opts.allowProxied },
|
||||
cachePurge: opts.cachePurge,
|
||||
})
|
||||
g.getRequestHeader = (_e: unknown, name: string) => opts.headers?.[name]
|
||||
g.createError = (input: { statusCode: number; statusMessage: string }) => Object.assign(new Error(input.statusMessage), input)
|
||||
g.readBody = async () => {
|
||||
if (opts.bodyError) throw new Error('invalid JSON')
|
||||
return opts.body
|
||||
}
|
||||
g.useStorage = (base: string) => {
|
||||
assert.equal(base, 'cache')
|
||||
return {
|
||||
getKeys: async (prefix?: string) => [...live].filter((k) => !prefix || k.startsWith(prefix + ':')),
|
||||
removeItem: async (key: string) => {
|
||||
live.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
const info = console.info
|
||||
console.info = (line: string) => {
|
||||
logs.push(line)
|
||||
}
|
||||
try {
|
||||
const route = (await import('../server/api/__site__/cache/purge.post.ts')).default as (e: unknown) => Promise<unknown>
|
||||
try {
|
||||
return { result: await route({}), status: 200, responseHeaders, logs, live }
|
||||
} catch (err) {
|
||||
return { result: undefined, status: (err as { statusCode?: number }).statusCode, responseHeaders, logs, live }
|
||||
}
|
||||
} finally {
|
||||
console.info = info
|
||||
}
|
||||
}
|
||||
|
||||
test('sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
|
||||
for (const token of [undefined, '', 'short']) {
|
||||
const r = await callRoute({ token, headers: { authorization: `Bearer ${String(token)}` }, keys })
|
||||
assert.equal(r.status, 404, `token ${JSON.stringify(token)}`)
|
||||
assert.equal(r.live.size, 1)
|
||||
}
|
||||
for (const name of ['x-forwarded-for', 'x-real-ip', 'forwarded']) {
|
||||
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}`, [name]: '203.0.113.9' }, keys })
|
||||
assert.equal(r.status, 404, name)
|
||||
assert.equal(r.live.size, 1)
|
||||
}
|
||||
for (const authorization of [undefined, 'Bearer wrong', `Basic ${TOKEN}`]) {
|
||||
const r = await callRoute({ token: TOKEN, headers: authorization ? { authorization } : {}, keys })
|
||||
assert.equal(r.status, 401, String(authorization))
|
||||
assert.equal(r.live.size, 1)
|
||||
assert.equal(r.responseHeaders['cache-control'], 'no-store')
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: route purges, answers the count and logs kind and slug only', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`, 'nitro:functions:_:x.json']
|
||||
const r = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, body: { kind: 'faq', slug: 'ship\nping' }, keys })
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(r.result, { purged: 2 })
|
||||
assert.deepEqual([...r.live], ['nitro:functions:_:x.json'])
|
||||
assert.equal(r.responseHeaders['cache-control'], 'no-store')
|
||||
assert.deepEqual(r.logs, ['[site-cache] purge: removed 2 route cache entries (faq shipping)'])
|
||||
assert.ok(!r.logs.join('').includes(TOKEN))
|
||||
|
||||
const bad = await callRoute({ token: TOKEN, headers: { authorization: `Bearer ${TOKEN}` }, bodyError: true, keys: [] })
|
||||
assert.equal(bad.status, 200)
|
||||
assert.deepEqual(bad.result, { purged: 0 })
|
||||
assert.deepEqual(bad.logs, ['[site-cache] purge: removed 0 route cache entries (unknown )'])
|
||||
})
|
||||
|
||||
test('sitePurge: allowProxied lets a proxied request reach the token check', () => {
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: true }), 'ok')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: 'Bearer ' + 'b'.repeat(32), proxied: true, allowProxied: true }), 'unauthorized')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: null, proxied: true, allowProxied: true }), 'unauthorized')
|
||||
assert.equal(authorizeSitePurge({ token: 'short', authorization: 'Bearer short', proxied: true, allowProxied: true }), 'disabled')
|
||||
assert.equal(authorizeSitePurge({ token: '', authorization: 'Bearer x', proxied: true, allowProxied: true }), 'disabled')
|
||||
// Without the flag, or with anything but true, a proxied request stays refused.
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true, allowProxied: false }), 'proxied')
|
||||
assert.equal(authorizeSitePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied')
|
||||
})
|
||||
|
||||
test('sitePurge: route accepts a proxied request with allowProxied and the right bearer', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`, `${SITE_ROUTE_CACHE_BASE}:_:faq.json`]
|
||||
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9', forwarded: 'for=203.0.113.9;proto=https' }
|
||||
for (const allowProxied of [true, 'true']) {
|
||||
const r = await callRoute({ token: TOKEN, allowProxied, headers, body: { kind: 'faq', slug: 'shipping' }, keys })
|
||||
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
assert.deepEqual(r.result, { purged: 2 })
|
||||
assert.equal(r.live.size, 0)
|
||||
}
|
||||
const wrong = await callRoute({ token: TOKEN, allowProxied: true, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
|
||||
assert.equal(wrong.status, 401)
|
||||
assert.equal(wrong.live.size, 2)
|
||||
for (const allowProxied of [false, undefined, 'false', 1]) {
|
||||
const r = await callRoute({ token: TOKEN, allowProxied, headers, keys })
|
||||
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
assert.equal(r.live.size, 2)
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: resolve config prefers a non-empty new token over the deprecated one', () => {
|
||||
const A = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A }, siteCache: { purgeToken: B } }), { token: A, allowProxied: false, deprecated: [] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: A, allowProxied: false }, siteCache: { purgeToken: '' } }), { token: A, allowProxied: false, deprecated: [] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: null, siteCache: { purgeToken: B } }), { token: B, allowProxied: false, deprecated: ['token'] })
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { token: '' }, siteCache: { purgeToken: '' } }), { token: '', allowProxied: false, deprecated: [] })
|
||||
})
|
||||
|
||||
test('sitePurge: resolve config turns allowProxied on for either flag, true or "true" only', () => {
|
||||
for (const value of [true, 'true']) {
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).allowProxied, true, `new ${JSON.stringify(value)}`)
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value } }).deprecated, [])
|
||||
const old = resolveSitePurgeConfig({ siteCache: { allowProxied: value } })
|
||||
assert.equal(old.allowProxied, true, `old ${JSON.stringify(value)}`)
|
||||
assert.deepEqual(old.deprecated, ['allowProxied'])
|
||||
// The new flag on: the old one is not counted as in use.
|
||||
assert.deepEqual(resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } }).deprecated, [])
|
||||
}
|
||||
for (const value of [false, undefined, 'false', 1, null, 'TRUE']) {
|
||||
const r = resolveSitePurgeConfig({ cachePurge: { allowProxied: value }, siteCache: { allowProxied: value } })
|
||||
assert.equal(r.allowProxied, false, JSON.stringify(value))
|
||||
assert.deepEqual(r.deprecated, [])
|
||||
}
|
||||
const B = 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
assert.deepEqual(resolveSitePurgeConfig({ siteCache: { purgeToken: B, allowProxied: 'true' } }).deprecated, ['token', 'allowProxied'])
|
||||
})
|
||||
|
||||
test('sitePurge: resolve config tolerates a missing or malformed config', () => {
|
||||
for (const config of [undefined, null, 'x', 42, {}, { cachePurge: 'x', siteCache: 7 }]) {
|
||||
assert.deepEqual(resolveSitePurgeConfig(config), { token: undefined, allowProxied: false, deprecated: [] }, JSON.stringify(config))
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: route reads the new token, falls back to the deprecated one, and the new one wins', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
|
||||
const NEW = 'n'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH)
|
||||
const r = await callRoute({ cachePurge: { token: TOKEN }, headers: { authorization: `Bearer ${TOKEN}` }, keys })
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(r.result, { purged: 1 })
|
||||
const old = await callRoute({ token: TOKEN, cachePurge: { token: '' }, headers: { authorization: `Bearer ${TOKEN}` }, keys })
|
||||
assert.equal(old.status, 200)
|
||||
const both = { token: TOKEN, cachePurge: { token: NEW } }
|
||||
assert.equal((await callRoute({ ...both, headers: { authorization: `Bearer ${NEW}` }, keys })).status, 200)
|
||||
const refused = await callRoute({ ...both, headers: { authorization: `Bearer ${TOKEN}` }, keys })
|
||||
assert.equal(refused.status, 401)
|
||||
assert.equal(refused.live.size, 1)
|
||||
const short = await callRoute({ cachePurge: { token: 'short' }, headers: { authorization: 'Bearer short' }, keys })
|
||||
assert.equal(short.status, 404)
|
||||
})
|
||||
|
||||
test('sitePurge: route lets a proxied request reach the token check with cachePurge.allowProxied', async () => {
|
||||
const keys = [`${SITE_ROUTE_CACHE_BASE}:_:index.json`]
|
||||
const headers = { authorization: `Bearer ${TOKEN}`, 'x-forwarded-for': '203.0.113.9' }
|
||||
for (const allowProxied of [true, 'true']) {
|
||||
const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys })
|
||||
assert.equal(r.status, 200, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
const wrong = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers: { ...headers, authorization: 'Bearer wrong' }, keys })
|
||||
assert.equal(wrong.status, 401)
|
||||
}
|
||||
// The deprecated flag with the new token still opens it.
|
||||
assert.equal((await callRoute({ token: '', allowProxied: true, cachePurge: { token: TOKEN }, headers, keys })).status, 200)
|
||||
for (const allowProxied of [false, undefined, 'false', 1]) {
|
||||
const r = await callRoute({ cachePurge: { token: TOKEN, allowProxied }, headers, keys })
|
||||
assert.equal(r.status, 404, `allowProxied ${JSON.stringify(allowProxied)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('sitePurge: redactSiteConfigStack removes the purge keys from every stack entry', () => {
|
||||
assert.deepEqual([...SITE_CONFIG_PURGE_KEYS], ['cachePurgeToken', 'cacheAllowProxied'])
|
||||
const stack: Record<string, unknown>[] = [
|
||||
{ url: 'https://acme.test', name: 'Acme', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' },
|
||||
{ _context: 'x' },
|
||||
]
|
||||
assert.equal(redactSiteConfigStack(stack), 2)
|
||||
assert.deepEqual(stack, [{ url: 'https://acme.test', name: 'Acme' }, { _context: 'x' }])
|
||||
assert.equal(redactSiteConfigStack(stack), 0)
|
||||
const spread = [{ cachePurgeToken: TOKEN }, { cachePurgeToken: TOKEN, indexable: true }]
|
||||
assert.equal(redactSiteConfigStack(spread), 2)
|
||||
assert.deepEqual(spread, [{}, { indexable: true }])
|
||||
for (const value of [undefined, null, 'stack', 7, {}, { cachePurgeToken: TOKEN }]) {
|
||||
assert.equal(redactSiteConfigStack(value), 0, JSON.stringify(value))
|
||||
}
|
||||
assert.equal(redactSiteConfigStack([null, undefined, 'x', 3]), 0)
|
||||
})
|
||||
|
||||
test('sitePurge: the deprecation message names variables, never a value', () => {
|
||||
assert.equal(sitePurgeDeprecationMessage([]), '')
|
||||
const token = sitePurgeDeprecationMessage(['token'])
|
||||
assert.match(token, /^\[site-cache\] /)
|
||||
assert.ok(token.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(!token.includes('NUXT_SITE_CACHE_ALLOW_PROXIED'))
|
||||
assert.match(token, /rotate/)
|
||||
assert.ok(!token.includes('\n'))
|
||||
const both = sitePurgeDeprecationMessage(['token', 'allowProxied'])
|
||||
assert.ok(both.includes('NUXT_SITE_CACHE_PURGE_TOKEN -> NUXT_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(both.includes('NUXT_SITE_CACHE_ALLOW_PROXIED -> NUXT_CACHE_PURGE_ALLOW_PROXIED'))
|
||||
assert.ok(sitePurgeDeprecationMessage(['allowProxied']).includes('NUXT_CACHE_PURGE_ALLOW_PROXIED'))
|
||||
})
|
||||
|
||||
type SiteConfigHook = (ctx: unknown) => void
|
||||
|
||||
/** Runs the shared Nitro plugin on a fake nitroApp with the given runtime config. */
|
||||
async function runPlugin(config: unknown) {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
g.defineNitroPlugin = (fn: unknown) => fn
|
||||
g.useRuntimeConfig = () => config
|
||||
const hooks: Record<string, SiteConfigHook[]> = {}
|
||||
const nitroApp = {
|
||||
hooks: {
|
||||
hook(name: string, fn: SiteConfigHook) {
|
||||
;(hooks[name] ??= []).push(fn)
|
||||
},
|
||||
},
|
||||
}
|
||||
const warnings: string[] = []
|
||||
const warn = console.warn
|
||||
console.warn = (line: string) => {
|
||||
warnings.push(line)
|
||||
}
|
||||
try {
|
||||
const plugin = (await import('../server/plugins/site-cache-purge.ts')).default as (app: unknown) => void
|
||||
plugin(nitroApp)
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
return { hooks, warnings }
|
||||
}
|
||||
|
||||
function resetWarned() {
|
||||
delete (globalThis as Record<symbol, unknown>)[SITE_PURGE_WARNED]
|
||||
}
|
||||
|
||||
test('sitePurge: plugin strips the purge keys on site-config:init', async () => {
|
||||
resetWarned()
|
||||
const { hooks, warnings } = await runPlugin({ cachePurge: { token: TOKEN } })
|
||||
assert.equal(hooks['site-config:init']?.length, 1)
|
||||
assert.deepEqual(warnings, [])
|
||||
const stack = [{ url: 'https://acme.test', cachePurgeToken: TOKEN, cacheAllowProxied: 'true' }, { _context: 'x' }]
|
||||
hooks['site-config:init'][0]({ siteConfig: { stack }, event: {} })
|
||||
assert.deepEqual(stack, [{ url: 'https://acme.test' }, { _context: 'x' }])
|
||||
for (const ctx of [undefined, null, {}, { siteConfig: {} }, { siteConfig: { stack: 'x' } }]) {
|
||||
hooks['site-config:init'][0](ctx)
|
||||
}
|
||||
resetWarned()
|
||||
})
|
||||
|
||||
test('sitePurge: plugin warns once per process about a deprecated name, without the value', async () => {
|
||||
resetWarned()
|
||||
const config = { siteCache: { purgeToken: TOKEN, allowProxied: 'true' } }
|
||||
const first = await runPlugin(config)
|
||||
assert.equal(first.warnings.length, 1)
|
||||
assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(first.warnings[0].includes('NUXT_SITE_CACHE_PURGE_TOKEN'))
|
||||
assert.ok(first.warnings[0].includes('NUXT_CACHE_PURGE_ALLOW_PROXIED'))
|
||||
assert.ok(!first.warnings[0].includes(TOKEN))
|
||||
// Another layer's copy of the plugin in the same process stays quiet.
|
||||
const second = await runPlugin(config)
|
||||
assert.deepEqual(second.warnings, [])
|
||||
assert.equal(second.hooks['site-config:init']?.length, 1)
|
||||
resetWarned()
|
||||
|
||||
const fresh = await runPlugin({ cachePurge: { token: TOKEN }, siteCache: { purgeToken: 'o'.repeat(MIN_SITE_PURGE_TOKEN_LENGTH) } })
|
||||
assert.deepEqual(fresh.warnings, [], 'the new name wins: no warning')
|
||||
const none = await runPlugin({})
|
||||
assert.deepEqual(none.warnings, [])
|
||||
resetWarned()
|
||||
})
|
||||
Reference in New Issue
Block a user