diff --git a/README.md b/README.md index a8c9ea9..f95bb56 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ 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, 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`: 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. - `scripts/check-git-install.sh ` 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 --expect ` 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 --kind post|category --slug --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 ( checks)`. diff --git a/tests/siteCache.test.ts b/tests/siteCache.test.ts new file mode 100644 index 0000000..0dc1f00 --- /dev/null +++ b/tests/siteCache.test.ts @@ -0,0 +1,129 @@ +// Site cache purge: route authorization, the route-cache purge, the purge +// body parsing and browser revalidation of route-cached responses. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + MIN_PURGE_TOKEN_LENGTH, + ROUTE_CACHE_BASE, + authorizePurge, + parsePurgeEvent, + privateRevalidate, + purgeRouteCache, +} from '../app/utils/siteCache.ts' + +const TOKEN = 'a'.repeat(MIN_PURGE_TOKEN_LENGTH) + +test('purge auth: a missing or short token disables the route', () => { + for (const token of [undefined, null, '', 'short', 'b'.repeat(MIN_PURGE_TOKEN_LENGTH - 1), 12345678901234567890, {}]) { + assert.equal(authorizePurge({ token, authorization: `Bearer ${String(token)}`, proxied: false }), 'disabled', `token ${JSON.stringify(token)}`) + } + // Disabled wins over proxied: an app without a token has no route at all. + assert.equal(authorizePurge({ token: '', authorization: 'Bearer x', proxied: true }), 'disabled') + assert.equal(MIN_PURGE_TOKEN_LENGTH, 32) +}) + +test('purge auth: a proxied request is refused before the token is checked', () => { + assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: true }), 'proxied') + assert.equal(authorizePurge({ token: TOKEN, authorization: 'Bearer wrong', proxied: true }), 'proxied') + assert.equal(authorizePurge({ token: TOKEN, authorization: null, proxied: true }), 'proxied') +}) + +test('purge auth: a missing or wrong bearer token is unauthorized', () => { + for (const authorization of [undefined, null, '', 'Bearer', 'Bearer ', `Basic ${TOKEN}`, `${TOKEN}`, `Bearer ${TOKEN}x`, `Bearer ${TOKEN.slice(1)}`, `Bearer ${TOKEN} extra`, 'Bearer ' + 'b'.repeat(32)]) { + assert.equal(authorizePurge({ token: TOKEN, authorization, proxied: false }), 'unauthorized', `authorization ${JSON.stringify(authorization)}`) + } + assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN}`, proxied: false }), 'ok') + assert.equal(authorizePurge({ token: TOKEN, authorization: `bearer ${TOKEN}`, proxied: false }), 'ok') + assert.equal(authorizePurge({ token: TOKEN, authorization: `Bearer ${TOKEN} `, proxied: false }), 'ok') +}) + +/** 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('purge removes every route cache entry and only those', async () => { + const routes = [ + `${ROUTE_CACHE_BASE}:_:cordyceps.abc123.json`, + `${ROUTE_CACHE_BASE}:_:cordyceps_payloadjson.def456.json`, + `${ROUTE_CACHE_BASE}:_:index.0a1b2c.json`, + `${ROUTE_CACHE_BASE}:_:rssxml.99.json`, + ] + const others = ['nitro:functions:_:journal.1.json', 'nitro:handlers:_:api.2.json', 'nitro:routesx:_:near.3.json', 'other:routes:_:x.json'] + const storage = fakeStorage([...routes, ...others]) + assert.equal(ROUTE_CACHE_BASE, 'nitro:routes') + assert.equal(await purgeRouteCache(storage), routes.length) + assert.deepEqual([...storage.removed].sort(), [...routes].sort()) + assert.deepEqual([...storage.live].sort(), [...others].sort()) + assert.equal(await purgeRouteCache(storage), 0) +}) + +test('purge ignores keys a storage returns outside the route cache', async () => { + const storage = { + removed: [] as string[], + getKeys: () => ['nitro:routes:_:a.json', 'nitro:functions:_:b.json'], + removeItem(key: string) { + this.removed.push(key) + }, + } + assert.equal(await purgeRouteCache(storage), 1) + assert.deepEqual(storage.removed, ['nitro:routes:_:a.json']) +}) + +test('purge event: kinds', () => { + assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'hello' }), { kind: 'post', slug: 'hello' }) + assert.deepEqual(parsePurgeEvent({ kind: 'category', slug: 'cordyceps' }), { kind: 'category', slug: 'cordyceps' }) + assert.deepEqual(parsePurgeEvent({ kind: 'tag', slug: 'x' }), { kind: 'unknown', slug: 'x' }) + assert.deepEqual(parsePurgeEvent({ kind: 'POST', slug: 'x' }), { kind: 'unknown', slug: 'x' }) + assert.deepEqual(parsePurgeEvent({ slug: 'x' }), { kind: 'unknown', slug: 'x' }) +}) + +test('purge event: control characters are removed from the slug', () => { + assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'a\nb\r\tc\u0000d\u001be\u007ff\u0085g' }), { kind: 'post', slug: 'abcdefg' }) + assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 'zażółć-gęślą' }), { kind: 'post', slug: 'zażółć-gęślą' }) +}) + +test('purge event: the slug is cut to 200 characters', () => { + assert.equal(parsePurgeEvent({ kind: 'post', slug: 'x'.repeat(500) }).slug.length, 200) + assert.equal(parsePurgeEvent({ kind: 'post', slug: 'x'.repeat(200) }).slug.length, 200) +}) + +test('purge event: a body that is not an object', () => { + for (const body of [null, undefined, 'post', 42, true, ['post', 'x']]) { + assert.deepEqual(parsePurgeEvent(body), { kind: 'unknown', slug: '' }, JSON.stringify(body)) + } + assert.deepEqual(parsePurgeEvent({ kind: 'post', slug: 7 }), { kind: 'post', slug: '' }) +}) + +test('revalidate: s-maxage without max-age gets max-age=0', () => { + assert.equal(privateRevalidate('s-maxage=300, stale-while-revalidate'), 'max-age=0, s-maxage=300, stale-while-revalidate') + assert.equal(privateRevalidate('s-maxage=120'), 'max-age=0, s-maxage=120') +}) + +test('revalidate: s-maxage with max-age replaces max-age', () => { + assert.equal(privateRevalidate('max-age=300, s-maxage=300, stale-while-revalidate'), 'max-age=0, s-maxage=300, stale-while-revalidate') + assert.equal(privateRevalidate('s-maxage=300,max-age=60'), 's-maxage=300,max-age=0') +}) + +test('revalidate: the 304 form of the route cache', () => { + assert.equal(privateRevalidate('public, max-age=300, s-maxage=300'), 'public, max-age=0, s-maxage=300') +}) + +test('revalidate: values without s-maxage are unchanged', () => { + assert.equal(privateRevalidate('public, max-age=31536000, immutable'), 'public, max-age=31536000, immutable') + assert.equal(privateRevalidate('no-store'), 'no-store') + assert.equal(privateRevalidate('no-cache'), 'no-cache') + assert.equal(privateRevalidate(''), '') +})