Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bc7729646 | ||
| 2deac88c0e |
23
README.md
23
README.md
@@ -3,7 +3,7 @@
|
||||
A Nuxt 4 layer that renders a menu of the SummerCMS menu plugin as an accessible site navigation.
|
||||
|
||||
```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
|
||||
@@ -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`.
|
||||
- **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>`.
|
||||
- **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.
|
||||
|
||||
## Usage
|
||||
@@ -65,9 +66,27 @@ Slots:
|
||||
|---|---|---|
|
||||
| `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.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.
|
||||
|
||||
## 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).
|
||||
- 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
|
||||
|
||||
The layer expects the menu plugin's public API:
|
||||
@@ -121,7 +140,7 @@ Commit, tag `vX.Y.Z` (annotated) and push the tag; apps pin `#vX.Y.Z` in their d
|
||||
## Testing
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ export default defineNuxtConfig({
|
||||
// comes from request input.
|
||||
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: '',
|
||||
},
|
||||
public: {
|
||||
menu: {
|
||||
// Same-origin base for browser fetches (NUXT_PUBLIC_MENU_API_BASE).
|
||||
@@ -30,4 +37,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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
39
server/api/__site__/cache/purge.post.ts
vendored
Normal file
39
server/api/__site__/cache/purge.post.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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);
|
||||
// 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 verdict = authorizeSitePurge({
|
||||
token: (config.siteCache as { purgeToken?: unknown } | undefined)?.purgeToken,
|
||||
authorization: getRequestHeader(event, 'authorization'),
|
||||
proxied: PROXY_HEADERS.some((name) => getRequestHeader(event, name) !== undefined),
|
||||
})
|
||||
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 }
|
||||
})
|
||||
91
server/lib/siteCachePurge.ts
Normal file
91
server/lib/siteCachePurge.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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), `ok` when
|
||||
* the Bearer value equals the token (compared in constant time over sha256
|
||||
* digests) and `unauthorized` otherwise.
|
||||
*/
|
||||
export function authorizeSitePurge({ token, authorization, proxied }: SitePurgeAuthInput): SitePurgeVerdict {
|
||||
if (typeof token !== 'string' || token.length < MIN_SITE_PURGE_TOKEN_LENGTH) return 'disabled'
|
||||
if (proxied) 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(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, /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)
|
||||
})
|
||||
|
||||
97
tests/sitePurge.test.ts
Normal file
97
tests/sitePurge.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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 = '7c34121ae06cf011b30a1dc5d787a94657b71b8768bed3a200be0f41566001b8'
|
||||
const SHARED_ROUTE_SHA256 = 'c459012280cdf59551e57f0440fcaeb24566a5ba6e3dccf31133e67a683f1c06'
|
||||
|
||||
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))
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user