- server/lib/siteCachePurge.ts: authorizeSitePurge, purgeSiteRouteCache,
parseSitePurgeEvent (kinds: lower-case letters and underscores)
- the route reads runtimeConfig.siteCache.purgeToken
(NUXT_SITE_CACHE_PURGE_TOKEN), 404 when off or proxied, 401 unauthorized
- route rule '/api/__site__/**': { cache: false }
- sitePurge specs with sha256 pins of the two shared files
- version 0.2.0
98 lines
4.7 KiB
TypeScript
98 lines
4.7 KiB
TypeScript
// 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))
|
|
}
|
|
})
|