Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4639c2020 | ||
|
|
dd0ee35085 | ||
|
|
3bc7729646 | ||
| 2deac88c0e |
24
README.md
24
README.md
@@ -3,7 +3,7 @@
|
|||||||
A Nuxt 4 layer that renders a menu of the SummerCMS menu plugin as an accessible site navigation.
|
A Nuxt 4 layer that renders a menu of the SummerCMS menu plugin as an accessible site navigation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.1.0
|
pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
@@ -20,6 +20,7 @@ The layer is neutral: it ships no site colours, no site copy and no Nuxt modules
|
|||||||
- **Active state**: the entry whose URL path equals the current route path (decoded, query, hash and trailing slash ignored; an absolute URL only on the request host) gets `aria-current="page"` and `menu-nav__link--active`; its ancestors get `menu-nav__item--trail`.
|
- **Active state**: the entry whose URL path equals the current route path (decoded, query, hash and trailing slash ignored; an absolute URL only on the request host) gets `aria-current="page"` and `menu-nav__link--active`; its ancestors get `menu-nav__item--trail`.
|
||||||
- **Dividers**: `aria-hidden` list items, a horizontal line in panels and a short vertical line at the top level on the desktop.
|
- **Dividers**: `aria-hidden` list items, a horizontal line in panels and a short vertical line at the top level on the desktop.
|
||||||
- **Empty states**: a missing menu (404), a failed request or a menu without entries renders nothing, or the `#fallback` slot. There is never an empty `<nav>`.
|
- **Empty states**: a missing menu (404), a failed request or a menu without entries renders nothing, or the `#fallback` slot. There is never an empty `<nav>`.
|
||||||
|
- **Site cache purge**: the shared route `POST /api/__site__/cache/purge` drops the site's route cache when the backend saves, deletes or reorders a menu or menu entry (or a FAQ or post, through the other core layers). See [Site cache purge](#site-cache-purge).
|
||||||
- **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server.
|
- **Rate limits**: server-side fetches forward the visitor's `x-forwarded-for` (also on route-cached renders), so the API's per-client throttle sees visitors, not the Nuxt server.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -65,9 +66,28 @@ Slots:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `runtimeConfig.menu.apiBase` | `http://127.0.0.1:8096/_menu/api/v1` | `NUXT_MENU_API_BASE` (server only, used for SSR) |
|
| `runtimeConfig.menu.apiBase` | `http://127.0.0.1:8096/_menu/api/v1` | `NUXT_MENU_API_BASE` (server only, used for SSR) |
|
||||||
| `runtimeConfig.public.menu.apiBase` | `/_menu/api/v1` | `NUXT_PUBLIC_MENU_API_BASE` (browser, same origin) |
|
| `runtimeConfig.public.menu.apiBase` | `/_menu/api/v1` | `NUXT_PUBLIC_MENU_API_BASE` (browser, same origin) |
|
||||||
|
| `runtimeConfig.siteCache.purgeToken` | `''` (route off) | `NUXT_SITE_CACHE_PURGE_TOKEN` (server only; at least 32 characters) |
|
||||||
|
|
||||||
The base URLs always come from runtime config, never from request input. In production the web server routes `/_menu/` to the SummerCMS binary.
|
The base URLs always come from runtime config, never from request input. In production the web server routes `/_menu/` to the SummerCMS binary.
|
||||||
|
|
||||||
|
## Site cache purge
|
||||||
|
|
||||||
|
A site that route-caches its pages (for example `routeRules: { '/**': { swr: 300 } }`) shows an admin edit only when the cache window ends. The layer ships the shared purge route that the SummerCMS backend calls after an admin save commits:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/__site__/cache/purge
|
||||||
|
Authorization: Bearer <NUXT_SITE_CACHE_PURGE_TOKEN>
|
||||||
|
{"kind":"menu_entry","slug":"partition:3"}
|
||||||
|
→ {"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.
|
||||||
|
- 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_SITE_CACHE_ALLOW_PROXIED=true` (`runtimeConfig.siteCache.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.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
The layer expects the menu plugin's public API:
|
The layer expects the menu plugin's public API:
|
||||||
@@ -121,7 +141,7 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm test # node --test: link rules, client, rendered NavItem markup
|
pnpm test # node --test: link rules, client, rendered NavItem markup, site cache purge
|
||||||
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
|
pnpm build:playground && pnpm smoke:ssr # SSR of the playground against testing/fixture-api.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ export default defineNuxtConfig({
|
|||||||
// comes from request input.
|
// comes from request input.
|
||||||
apiBase: 'http://127.0.0.1:8096/_menu/api/v1',
|
apiBase: 'http://127.0.0.1:8096/_menu/api/v1',
|
||||||
},
|
},
|
||||||
|
siteCache: {
|
||||||
|
// Server-only bearer token of the shared site cache purge route
|
||||||
|
// POST /api/__site__/cache/purge (NUXT_SITE_CACHE_PURGE_TOKEN), the
|
||||||
|
// backend's site_cache.purge_token. Shorter than 32 characters, the
|
||||||
|
// route answers 404.
|
||||||
|
purgeToken: '',
|
||||||
|
// NUXT_SITE_CACHE_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,
|
||||||
|
},
|
||||||
public: {
|
public: {
|
||||||
menu: {
|
menu: {
|
||||||
// Same-origin base for browser fetches (NUXT_PUBLIC_MENU_API_BASE).
|
// Same-origin base for browser fetches (NUXT_PUBLIC_MENU_API_BASE).
|
||||||
@@ -30,4 +43,12 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The shared site routes are never route-cached. Nitro wraps every
|
||||||
|
// handler matching an app rule such as '/**': { swr } in its cached
|
||||||
|
// handler, which keys by path, does not skip POST and hides request
|
||||||
|
// headers, so the purge route would answer from the cache without running.
|
||||||
|
routeRules: {
|
||||||
|
'/api/__site__/**': { cache: false },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vue-menu-layer",
|
"name": "vue-menu-layer",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./nuxt.config.ts",
|
"main": "./nuxt.config.ts",
|
||||||
|
|||||||
44
server/api/__site__/cache/purge.post.ts
vendored
Normal file
44
server/api/__site__/cache/purge.post.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.siteCache.allowProxied (NUXT_SITE_CACHE_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.
|
||||||
|
//
|
||||||
|
// 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'
|
||||||
|
|
||||||
|
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 siteCache = config.siteCache as { purgeToken?: unknown; allowProxied?: unknown } | undefined
|
||||||
|
const verdict = authorizeSitePurge({
|
||||||
|
token: siteCache?.purgeToken,
|
||||||
|
authorization: getRequestHeader(event, 'authorization'),
|
||||||
|
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
|
||||||
|
allowProxied: siteCache?.allowProxied === true || siteCache?.allowProxied === 'true',
|
||||||
|
})
|
||||||
|
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 }
|
||||||
|
})
|
||||||
98
server/lib/siteCachePurge.ts
Normal file
98
server/lib/siteCachePurge.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
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 (runtimeConfig.siteCache.purgeToken). */
|
||||||
|
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
|
||||||
|
/**
|
||||||
|
* runtimeConfig.siteCache.allowProxied: 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 }
|
||||||
|
}
|
||||||
@@ -76,3 +76,59 @@ test('isNotFound reads status, statusCode and response.status', () => {
|
|||||||
assert.equal(isNotFound({ status: 500 }), false)
|
assert.equal(isNotFound({ status: 500 }), false)
|
||||||
assert.equal(isNotFound(null), false)
|
assert.equal(isNotFound(null), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Plan 06 coverage: useMenuApi picks the base. The composable reads Nuxt
|
||||||
|
// globals and import.meta.server, so the test loads its source with
|
||||||
|
// import.meta.server replaced by a flag, next to the original (its relative
|
||||||
|
// import resolves), with stubbed globals.
|
||||||
|
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
type FetchCall = { path: string; opts: { baseURL: string; query?: Record<string, string>; headers?: Record<string, string> } }
|
||||||
|
|
||||||
|
let loads = 0
|
||||||
|
|
||||||
|
async function useMenuApiAs(server: boolean, request: { context?: Record<string, unknown>; xff?: string; peer?: string } = {}) {
|
||||||
|
const calls: FetchCall[] = []
|
||||||
|
const g = globalThis as Record<string, unknown>
|
||||||
|
g.__menuLayerServer = server
|
||||||
|
g.useRuntimeConfig = () => ({ menu: { apiBase: 'http://127.0.0.1:8096/_menu/api/v1' }, public: { menu: { apiBase: '/_menu/api/v1' } } })
|
||||||
|
g.useRequestEvent = () => ({ context: request.context ?? {}, node: { req: { socket: { remoteAddress: request.peer } } } })
|
||||||
|
g.useRequestHeaders = () => (request.xff === undefined ? {} : { 'x-forwarded-for': request.xff })
|
||||||
|
g.$fetch = async (path: string, opts: FetchCall['opts']) => {
|
||||||
|
calls.push({ path, opts })
|
||||||
|
return MENU
|
||||||
|
}
|
||||||
|
const source = join(dirname(fileURLToPath(import.meta.url)), '..', 'app', 'composables', 'useMenuApi.ts')
|
||||||
|
const out = join(dirname(source), `.useMenuApi-${process.pid}-${++loads}.compiled.ts`)
|
||||||
|
writeFileSync(out, readFileSync(source, 'utf8').replaceAll('import.meta.server', '(globalThis as Record<string, unknown>).__menuLayerServer'))
|
||||||
|
try {
|
||||||
|
const mod = (await import(pathToFileURL(out).href)) as { useMenuApi: () => ReturnType<typeof createMenuClient> }
|
||||||
|
return { client: mod.useMenuApi(), calls }
|
||||||
|
} finally {
|
||||||
|
rmSync(out, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the server base is used on the server, with the visitor forwarded', async () => {
|
||||||
|
const { client, calls } = await useMenuApiAs(true, { xff: '203.0.113.7', peer: '10.0.0.2' })
|
||||||
|
await client.getMenu('main menu', 'pl')
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
{ path: '/menus/main%20menu', opts: { baseURL: 'http://127.0.0.1:8096/_menu/api/v1', query: { locale: 'pl' }, headers: { 'x-forwarded-for': '203.0.113.7, 10.0.0.2' } } },
|
||||||
|
])
|
||||||
|
// A value recorded on the request context (route cache) wins.
|
||||||
|
const recorded = await useMenuApiAs(true, { context: { menuForwardedFor: '198.51.100.9' }, xff: '1.1.1.1', peer: '10.0.0.2' })
|
||||||
|
await recorded.client.getMenu('main')
|
||||||
|
assert.deepEqual(recorded.calls[0]!.opts.headers, { 'x-forwarded-for': '198.51.100.9' })
|
||||||
|
// Nothing to forward: no header at all.
|
||||||
|
const bare = await useMenuApiAs(true)
|
||||||
|
await bare.client.getMenu('main')
|
||||||
|
assert.deepEqual(bare.calls[0]!.opts, { baseURL: 'http://127.0.0.1:8096/_menu/api/v1', query: undefined, headers: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the public base is used in the browser, without forwarded headers', async () => {
|
||||||
|
const { client, calls } = await useMenuApiAs(false, { xff: '203.0.113.7', peer: '10.0.0.2' })
|
||||||
|
await client.getMenu('main', 'en')
|
||||||
|
assert.deepEqual(calls, [{ path: '/menus/main', opts: { baseURL: '/_menu/api/v1', query: { locale: 'en' } } }])
|
||||||
|
})
|
||||||
|
|||||||
@@ -135,3 +135,72 @@ test('rendered: an entry with children and a URL keeps its link and gets a label
|
|||||||
assert.match(html, /<button type="button" class="menu-nav__subtoggle" aria-expanded="true" aria-controls="m-0-sub">.*Show submenu: Blog/)
|
assert.match(html, /<button type="button" class="menu-nav__subtoggle" aria-expanded="true" aria-controls="m-0-sub">.*Show submenu: Blog/)
|
||||||
assert.match(html, /menu-nav__item--open/)
|
assert.match(html, /menu-nav__item--open/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Plan 06 coverage of the D-18 link rules.
|
||||||
|
test('every refused scheme and spacing renders as text without an href', async () => {
|
||||||
|
for (const url of ['javascript:alert(1)', 'JAVASCRIPT:alert(1)', 'JaVaScRiPt:x', 'data:text/html,x', 'DATA:x', 'vbscript:msgbox(1)', 'VBScript:x', 'file:///etc/passwd', 'ftp://a.test/x', ' https://a.test', 'https://a.test/\u0000']) {
|
||||||
|
assert.equal(entryHref(entry(url)), null, url)
|
||||||
|
assert.equal(linkKind(entry(url)), 'text', url)
|
||||||
|
assert.equal(isActive(entry(url), '/', 'site.test'), false, url)
|
||||||
|
}
|
||||||
|
const html = await renderNavItem({
|
||||||
|
entry: entry('#', {
|
||||||
|
title: 'Parent',
|
||||||
|
items: [
|
||||||
|
entry('JAVASCRIPT:alert(1)', { id: 2, title: 'Upper' }),
|
||||||
|
entry('vbscript:x', { id: 3, title: 'Vb' }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
routePath: '/',
|
||||||
|
})
|
||||||
|
assert.ok(!/href="(javascript|vbscript)/i.test(html), html)
|
||||||
|
assert.match(html, /<span class="menu-nav__text"[^>]*>Upper<\/span>/)
|
||||||
|
assert.match(html, /<span class="menu-nav__text"[^>]*>Vb<\/span>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('protocol-relative URLs are never site paths: an anchor, never active', () => {
|
||||||
|
for (const url of ['//evil.test/x', '/\\evil.test/x', '//site.test/blog']) {
|
||||||
|
assert.equal(linkKind(entry(url)), 'anchor', url)
|
||||||
|
assert.equal(entryPath(entry(url), 'site.test'), null, url)
|
||||||
|
assert.equal(isActive(entry(url), '/x', 'site.test'), false, url)
|
||||||
|
assert.equal(isActive(entry(url), '/blog', 'site.test'), false, url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rel is added only for _blank, whatever the URL kind', () => {
|
||||||
|
for (const target of ['_blank']) assert.equal(linkRel(target), 'noopener noreferrer')
|
||||||
|
for (const target of ['_self', '', undefined, '_BLANK', '_top']) assert.equal(linkRel(target as string | undefined), undefined, String(target))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('active matching ignores query, hash and a trailing slash, except that / is only /', () => {
|
||||||
|
assert.equal(isActive(entry('/'), '/', 'site.test'), true)
|
||||||
|
assert.equal(isActive(entry('/'), '/?utm=x#top', 'site.test'), true)
|
||||||
|
assert.equal(isActive(entry('/'), '/blog', 'site.test'), false)
|
||||||
|
assert.equal(isActive(entry('/blog#x'), '/blog?page=2', 'site.test'), true)
|
||||||
|
assert.equal(isActive(entry('/blog///'), '/blog', 'site.test'), true)
|
||||||
|
assert.equal(isActive(entry('/Blog'), '/blog', 'site.test'), false)
|
||||||
|
assert.equal(isActive(entry('/grzyby%20le%C5%9Bne'), '/grzyby leśne/', 'site.test'), true)
|
||||||
|
assert.equal(isActive(entry('#'), '/', 'site.test'), false)
|
||||||
|
assert.equal(isActive(entry('#faq'), '/', 'site.test'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an absolute URL is active only on the same host', () => {
|
||||||
|
assert.equal(isActive(entry('https://SITE.test/blog/'), '/blog', 'site.test'), true)
|
||||||
|
assert.equal(isActive(entry('http://site.test/blog'), '/blog', 'Site.Test'), true)
|
||||||
|
assert.equal(isActive(entry('https://site.test:8443/blog'), '/blog', 'site.test'), false)
|
||||||
|
assert.equal(isActive(entry('https://site.test:8443/blog'), '/blog', 'site.test:8443'), true)
|
||||||
|
assert.equal(isActive(entry('https://other.test/blog'), '/blog', 'site.test'), false)
|
||||||
|
assert.equal(isActive(entry('https://site.test/blog'), '/blog', ''), false)
|
||||||
|
assert.equal(isActive(entry('mailto:a@site.test'), '/', 'site.test'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the trail marks ancestors only, never the active entry itself', () => {
|
||||||
|
const leaf = entry('/a')
|
||||||
|
assert.equal(inTrail(leaf, '/a', 'site.test'), false)
|
||||||
|
const parent = entry('/a', { items: [entry('/a/b')] })
|
||||||
|
assert.equal(inTrail(parent, '/a', 'site.test'), false)
|
||||||
|
assert.equal(inTrail(parent, '/a/b', 'site.test'), true)
|
||||||
|
const deep = entry(null, { items: [entry(null, { items: [entry(null, { items: [entry('/x/y/z/')] })] })] })
|
||||||
|
assert.equal(inTrail(deep, '/x/y/z', 'site.test'), true)
|
||||||
|
assert.equal(inTrail(entry('/a', { items: undefined as unknown as MenuEntry[] }), '/a', 'site.test'), false)
|
||||||
|
})
|
||||||
|
|||||||
247
tests/sitePurge.test.ts
Normal file
247
tests/sitePurge.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// Shared site cache purge: route authorization, the route-cache purge, the
|
||||||
|
// purge body parsing 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_ROUTE_CACHE_BASE,
|
||||||
|
authorizeSitePurge,
|
||||||
|
parseSitePurgeEvent,
|
||||||
|
purgeSiteRouteCache,
|
||||||
|
} from '../server/lib/siteCachePurge.ts'
|
||||||
|
|
||||||
|
// The sha256 of the two 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 = '0ea9f0f936134516872a2edf9b8e09feedddfa23e9da05c57d06c772193b260e'
|
||||||
|
const SHARED_ROUTE_SHA256 = '8f86226074abf10c2ecc1d181b6957e90ed8d900589a6372214f50cdbfcd532c'
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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; 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 } })
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user