import { strict as assert } from 'node:assert'; import { rm } from 'node:fs/promises'; import path from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import type { FastifyInstance } from 'fastify'; import { LoginThrottle, SESSION_COOKIE, createSessionToken, csrfToken, hashPassword } from '../src/auth.ts'; import { parseSiteConfig } from '../src/config.ts'; import { buildApp } from '../src/server.ts'; import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts'; const ASSETS = path.join(import.meta.dirname, '..', 'public'); const SECRET = 'admin-test-secret'; const PASSWORD = 'a good long password'; let dataDir: string; let app: FastifyInstance; let session: string; let csrf: string; const CLOCK = testClock('2026-05-01T06:00:00.000Z'); async function build( throttle?: LoginThrottle, limits?: { maxUploadBytes?: number; maxPagesPerSet?: number }, ): Promise { const built = await buildApp({ config: parseSiteConfig({ site: 'menu.example.test', brand: { name: 'Hops n Grains' }, sections: [ { slug: 'food', label: 'Food', order: 1 }, { slug: 'bar', label: 'Bar', order: 2 }, ], ...(limits === undefined ? {} : { limits }), }), dataDir, sessionSecret: SECRET, adminPasswordHash: await hashPassword(PASSWORD), assetsDir: ASSETS, secureCookies: false, logLevel: 'silent', pipeline: new FakePipeline(), now: CLOCK.now, ...(throttle === undefined ? {} : { throttle }), }); await built.ready(); return built; } beforeEach(async () => { dataDir = await tempDir('admin'); app = await build(); session = createSessionToken(SECRET, 60 * 60 * 1000, new Date()); csrf = csrfToken(session, SECRET); }); afterEach(async () => { await app.close(); await rm(dataDir, { recursive: true, force: true }); }); const cookies = () => ({ [SESSION_COOKIE]: session }); async function newSet(section = 'bar', label = 'Bar Menu', validUntil: string | null = null) { const response = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), headers: { 'x-csrf': csrf }, payload: { section, label, ...(validUntil === null ? {} : { validUntil }) }, }); const location = String(response.headers['location'] ?? ''); return { response, id: location.replace('/admin/sets/', '') }; } async function upload(id: string, name: string) { const boundary = '----menuadminboundary'; const payload = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="${name}"\r\nContent-Type: image/jpeg\r\n\r\n`, ), pngBytes(name), Buffer.from(`\r\n--${boundary}--\r\n`), ]); return app.inject({ method: 'POST', url: `/admin/sets/${id}/pages`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json', 'content-type': `multipart/form-data; boundary=${boundary}`, }, payload, }); } describe('signing in', () => { it('sends an anonymous visitor to the login form', async () => { const dashboard = await app.inject({ method: 'GET', url: '/admin' }); assert.equal(dashboard.statusCode, 303); assert.equal(dashboard.headers['location'], '/admin/login'); const login = await app.inject({ method: 'GET', url: '/admin/login' }); assert.equal(login.statusCode, 200); assert.match(login.body, /type="password"/); }); it('rejects a wrong password and accepts the right one', async () => { const wrong = await app.inject({ method: 'POST', url: '/admin/login', payload: { password: 'not it' }, }); assert.equal(wrong.statusCode, 401); assert.equal(wrong.headers['set-cookie'], undefined); assert.match(wrong.body, /not right/); const right = await app.inject({ method: 'POST', url: '/admin/login', payload: { password: PASSWORD }, }); assert.equal(right.statusCode, 303); const cookie = String(right.headers['set-cookie']); assert.match(cookie, /menu_session=/); assert.match(cookie, /HttpOnly/); assert.match(cookie, /SameSite=Lax/); }); it('throttles repeated failures per IP', async () => { const throttled = await build(new LoginThrottle({ maxFailures: 2, windowMs: 60_000 })); for (let attempt = 0; attempt < 2; attempt += 1) { await throttled.inject({ method: 'POST', url: '/admin/login', payload: { password: 'no' } }); } const blocked = await throttled.inject({ method: 'POST', url: '/admin/login', payload: { password: 'no' }, }); assert.equal(blocked.statusCode, 429); assert.ok(Number(blocked.headers['retry-after']) > 0); await throttled.close(); }); it('signs out', async () => { const response = await app.inject({ method: 'POST', url: '/admin/logout', cookies: cookies() }); assert.equal(response.statusCode, 303); assert.match(String(response.headers['set-cookie']), /menu_session=;/); }); }); describe('guarding the admin', () => { it('needs a session for the dashboard and the editor', async () => { assert.equal((await app.inject({ method: 'GET', url: '/admin' })).statusCode, 303); const { id } = await newSet(); assert.equal((await app.inject({ method: 'GET', url: `/admin/sets/${id}` })).statusCode, 303); }); it('needs a CSRF token on mutations', async () => { const missing = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), payload: { section: 'bar', label: 'Bar Menu' }, }); assert.equal(missing.statusCode, 403); const wrong = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), headers: { 'x-csrf': 'not-the-token' }, payload: { section: 'bar', label: 'Bar Menu' }, }); assert.equal(wrong.statusCode, 403); // A token minted for a different session must not work either. const otherSession = createSessionToken(SECRET, 60_000, new Date()); const stolen = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), headers: { 'x-csrf': csrfToken(otherSession, SECRET) }, payload: { section: 'bar', label: 'Bar Menu' }, }); assert.equal(stolen.statusCode, 403); }); it('rejects a cross-site mutation even with a session', async () => { const response = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), headers: { 'x-csrf': csrf, 'sec-fetch-site': 'cross-site' }, payload: { section: 'bar', label: 'Bar Menu' }, }); assert.equal(response.statusCode, 403); }); }); describe('the dashboard', () => { it('lists Sections, their Current set and their Drafts', async () => { const { id } = await newSet('bar', 'Bar Menu'); await upload(id, 'one.jpg'); await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); await newSet('bar', 'Bar Menu, next'); const response = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() }); assert.equal(response.statusCode, 200); assert.match(response.body, /Bar Menu/); assert.match(response.body, /Bar Menu, next/); assert.match(response.body, /pill--current/); assert.match(response.body, /pill--draft/); assert.match(response.body, /No menu published yet/); assert.match(response.body, /Food Menu/); }); it('carries a notice through a redirect', async () => { const response = await app.inject({ method: 'GET', url: '/admin?notice=Saved.', cookies: cookies(), }); assert.match(response.body, /Saved\./); }); }); describe('the editor', () => { it('creates a Draft and shows the upload controls', async () => { const { response, id } = await newSet('bar', 'Bar Menu', '2026-06-26'); assert.equal(response.statusCode, 303); assert.equal(response.headers['location'], `/admin/sets/${id}`); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); assert.equal(editor.statusCode, 200); assert.match(editor.body, /data-drop/); assert.match(editor.body, /data-pages-admin/); assert.match(editor.body, /enctype="multipart\/form-data"/); assert.match(editor.body, new RegExp(`data-csrf="${csrf}"`)); assert.match(editor.body, /No pages yet/); assert.match(editor.body, /value="2026-06-26"/); }); it('refuses a Section it does not know', async () => { const response = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), headers: { 'x-csrf': csrf }, payload: { section: 'pizza', label: 'Pizza' }, }); assert.equal(response.statusCode, 400); }); it('updates the label and valid-until date', async () => { const { id } = await newSet(); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/update`, cookies: cookies(), headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` }, payload: { label: 'Bar Menu, summer', validUntil: '2026-09-30' }, }); assert.equal(response.statusCode, 303); const set = await app.menu.store.get(id); assert.equal(set.manifest.label, 'Bar Menu, summer'); assert.equal(set.manifest.validUntil, '2026-09-30'); }); }); describe('uploading Pages', () => { it('accepts a multipart file and reports the count', async () => { const { id } = await newSet(); const response = await upload(id, 'IMG_0001.JPG'); assert.equal(response.statusCode, 200); assert.deepEqual(response.json(), { added: 1, failed: [] }); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); assert.match(editor.body, /IMG_0001\.JPG/); assert.match(editor.body, /\/media\/thumb-/); }); it('reports a file the pipeline will not accept, by name', async () => { const { id } = await newSet(); const boundary = '----x'; const payload = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="notes.pdf"\r\nContent-Type: application/pdf\r\n\r\nnot an image\r\n--${boundary}--\r\n`, ), ]); // The fake pipeline accepts anything, so this exercises the wiring only: // a real rejection is covered by the pipeline tests. const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/pages`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json', 'content-type': `multipart/form-data; boundary=${boundary}`, }, payload, }); assert.equal(response.statusCode, 200); }); it('refuses to add a Page to a published set', async () => { const { id } = await newSet(); await upload(id, 'one.jpg'); await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); const response = await upload(id, 'two.jpg'); assert.equal(response.statusCode, 409); }); it('reports a file over the upload limit, stores nothing, and stays healthy', async () => { const limited = await build(undefined, { maxUploadBytes: 2048 }); const created = await limited.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), headers: { 'x-csrf': csrf }, payload: { section: 'bar', label: 'Bar Menu' }, }); const id = String(created.headers['location']).replace('/admin/sets/', ''); const boundary = '----oversize'; const payload = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="huge.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`, ), Buffer.alloc(8 * 1024, 7), Buffer.from(`\r\n--${boundary}--\r\n`), ]); const response = await limited.inject({ method: 'POST', url: `/admin/sets/${id}/pages`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json', 'content-type': `multipart/form-data; boundary=${boundary}`, }, payload, }); const body = response.json() as { added: number; failed: { error: string }[] }; assert.equal(response.statusCode, 413); assert.equal(body.added, 0); assert.equal(body.failed.length, 1, 'the reason is reported once, not twice'); assert.match(body.failed.map((item) => item.error).join(' '), /larger than the 2 KB limit/); assert.deepEqual((await limited.menu.store.get(id)).manifest.pages, []); assert.equal((await limited.inject({ method: 'GET', url: '/healthz' })).statusCode, 200); await limited.close(); }); it('removes a Page', async () => { const { id } = await newSet(); await upload(id, 'one.jpg'); const set = await app.menu.store.get(id); const pageId = set.manifest.pages[0]!.id; const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/pages/${pageId}/delete`, cookies: cookies(), headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` }, }); assert.equal(response.statusCode, 303); assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0); }); }); describe('ordering', () => { async function threePages() { const { id } = await newSet(); await upload(id, 'one.jpg'); await upload(id, 'two.jpg'); await upload(id, 'three.jpg'); const set = await app.menu.store.get(id); return { id, ids: set.manifest.pages.map((page) => page.id) }; } it('moves a Page up with a form post', async () => { const { id, ids } = await threePages(); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/move`, cookies: cookies(), headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` }, payload: { pageId: ids[1], direction: 'up' }, }); assert.equal(response.statusCode, 303); assert.deepEqual( (await app.menu.store.get(id)).manifest.pages.map((page) => page.id), [ids[1], ids[0], ids[2]], ); }); it('ignores a move past either end', async () => { const { id, ids } = await threePages(); await app.inject({ method: 'POST', url: `/admin/sets/${id}/move`, cookies: cookies(), headers: { 'x-csrf': csrf, referer: `/admin/sets/${id}` }, payload: { pageId: ids[0], direction: 'up' }, }); assert.deepEqual( (await app.menu.store.get(id)).manifest.pages.map((page) => page.id), ids, ); }); it('reorders from a JSON list, and rejects a bad one', async () => { const { id, ids } = await threePages(); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/reorder`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json' }, payload: { pageIds: [ids[2], ids[0], ids[1]] }, }); assert.equal(response.statusCode, 200); assert.deepEqual( (await app.menu.store.get(id)).manifest.pages.map((page) => page.id), [ids[2], ids[0], ids[1]], ); const bad = await app.inject({ method: 'POST', url: `/admin/sets/${id}/reorder`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json' }, payload: { pageIds: [ids[0]] }, }); assert.equal(bad.statusCode, 400); }); }); describe('publishing, restoring and deleting', () => { it('publishes a Draft and sends the admin to the live menu', async () => { const { id } = await newSet('bar', 'Bar Menu', '2026-06-26'); await upload(id, 'one.jpg'); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); assert.equal(response.statusCode, 303); assert.equal(response.headers['location'], '/bar/'); assert.equal((await app.menu.store.get(id)).slug, '2026-06-26'); }); it('refuses to publish an empty set', async () => { const { id } = await newSet(); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); assert.equal(response.statusCode, 409); }); it('makes an archived set Current again', async () => { const first = await newSet('bar', 'Bar Menu', '2026-06-26'); await upload(first.id, 'one.jpg'); await app.inject({ method: 'POST', url: `/admin/sets/${first.id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); CLOCK.advance(60_000); const second = await newSet('bar', 'Bar Menu, next'); await upload(second.id, 'two.jpg'); await app.inject({ method: 'POST', url: `/admin/sets/${second.id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); CLOCK.advance(60_000); const response = await app.inject({ method: 'POST', url: `/admin/sets/${first.id}/restore`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); assert.equal(response.statusCode, 303); const current = (await app.menu.store.currentBySection()).get('bar'); assert.equal(current?.manifest.id, first.id); }); it('deletes a Draft and refuses to delete the Current set without confirmation', async () => { const { id } = await newSet(); await upload(id, 'one.jpg'); await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); const unconfirmed = await app.inject({ method: 'POST', url: `/admin/sets/${id}/delete`, cookies: cookies(), headers: { 'x-csrf': csrf }, payload: {}, }); assert.equal(unconfirmed.statusCode, 409); const confirmed = await app.inject({ method: 'POST', url: `/admin/sets/${id}/delete`, cookies: cookies(), headers: { 'x-csrf': csrf }, payload: { confirm: 'yes' }, }); assert.equal(confirmed.statusCode, 303); assert.deepEqual(await app.menu.store.list(), []); }); }); describe('forms as a browser sends them', () => { // A real browser posts form fields, not an x-csrf header. These tests exist // because the header path passed while every button in the UI was broken. function csrfFrom(body: string): string { const match = body.match(/name="csrf" value="([^"]+)"/); assert.ok(match, 'the page carries no CSRF field'); return match[1]!; } const encoded = (fields: Record) => ({ headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: new URLSearchParams(fields).toString(), }); it('creates a Draft from the dashboard form, with no CSRF header', async () => { const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() }); const response = await app.inject({ method: 'POST', url: '/admin/sets', cookies: cookies(), ...encoded({ csrf: csrfFrom(dashboard.body), section: 'bar', label: 'Bar Menu' }), }); assert.equal(response.statusCode, 303); assert.match(String(response.headers['location']), /^\/admin\/sets\//); }); it('publishes from the editor form, with no CSRF header', async () => { const { id } = await newSet(); await upload(id, 'one.jpg'); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), ...encoded({ csrf: csrfFrom(editor.body) }), }); assert.equal(response.statusCode, 303); assert.equal(response.headers['location'], '/bar/'); }); it('moves a Page with the up button as a browser sends it', async () => { const { id } = await newSet(); await upload(id, 'one.jpg'); await upload(id, 'two.jpg'); const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/move`, cookies: cookies(), headers: { 'content-type': 'application/x-www-form-urlencoded', referer: `/admin/sets/${id}`, }, payload: new URLSearchParams({ csrf: csrfFrom(editor.body), pageId: before[1]!, direction: 'up', }).toString(), }); assert.equal(response.statusCode, 303); assert.deepEqual( (await app.menu.store.get(id)).manifest.pages.map((page) => page.id), [before[1], before[0]], ); }); it('accepts the token as a multipart field, the way the no-JS upload form sends it', async () => { const { id } = await newSet(); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); const token = csrfFrom(editor.body); const boundary = '----formboundary'; const payload = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`, ), Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`, ), pngBytes('one'), Buffer.from(`\r\n--${boundary}--\r\n`), ]); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/pages`, cookies: cookies(), headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, referer: `/admin/sets/${id}`, }, payload, }); assert.equal(response.statusCode, 303); assert.equal((await app.menu.store.get(id)).manifest.pages.length, 1); }); it('refuses a multipart upload that carries no token at all', async () => { const { id } = await newSet(); const boundary = '----formboundary'; const payload = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`, ), pngBytes('one'), Buffer.from(`\r\n--${boundary}--\r\n`), ]); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/pages`, cookies: cookies(), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, payload, }); assert.equal(response.statusCode, 403); assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0); }); it('puts a CSRF field in every form that mutates', async () => { const { id } = await newSet('bar', 'Bar Menu'); await upload(id, 'one.jpg'); const dashboard = (await app.inject({ method: 'GET', url: '/admin', cookies: cookies() })).body; const editor = (await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() })).body; for (const [where, body] of [['dashboard', dashboard], ['editor', editor]] as const) { const forms = [...body.matchAll(/]*>[\s\S]*?<\/form>/g)].map((match) => match[0]); assert.ok(forms.length > 0, `no forms found in the ${where}`); for (const form of forms) { assert.match(form, /name="csrf"/, `a ${where} form carries no CSRF field:\n${form}`); } } }); }); describe('reordering', () => { async function publishBar(label = 'Bar Menu', validUntil: string | null = null) { const { id } = await newSet('bar', label, validUntil); await upload(id, 'one.jpg'); await upload(id, 'two.jpg'); const response = await app.inject({ method: 'POST', url: `/admin/sets/${id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); assert.equal(response.statusCode, 303); return id; } it('offers dragging on the Current set and the new order reaches customers', async () => { const id = await publishBar('Bar Menu', '2026-06-26'); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); assert.equal(editor.statusCode, 200); assert.match(editor.body, /data-pages-admin/); assert.match(editor.body, /page-admin__handle/); assert.equal((editor.body.match(/draggable="true"/g) ?? []).length, 2); assert.match(editor.body, /the menu customers are seeing/); assert.doesNotMatch(editor.body, /Make current/); const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id); const reorder = await app.inject({ method: 'POST', url: `/admin/sets/${id}/reorder`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json' }, payload: { pageIds: [before[1], before[0]] }, }); assert.equal(reorder.statusCode, 200); const live = await app.inject({ method: 'GET', url: '/bar/' }); const served = [...live.body.matchAll(/\/media\/([0-9a-f-]{36})\./g)].map((match) => match[1]); assert.deepEqual([...new Set(served)], [before[1], before[0]]); }); it('keeps an Archive read-only, with no handle to pull', async () => { const archived = await publishBar('Bar Menu', '2026-06-26'); CLOCK.advance(60_000); const next = await newSet('bar', 'Bar Menu, next'); await upload(next.id, 'two.jpg'); await app.inject({ method: 'POST', url: `/admin/sets/${next.id}/publish`, cookies: cookies(), headers: { 'x-csrf': csrf }, }); const editor = await app.inject({ method: 'GET', url: `/admin/sets/${archived}`, cookies: cookies(), }); assert.equal(editor.statusCode, 200); assert.match(editor.body, /This is an older menu/); assert.doesNotMatch(editor.body, /page-admin__handle/); assert.doesNotMatch(editor.body, /data-pages-admin/); assert.equal((editor.body.match(/draggable="false"/g) ?? []).length, 2); assert.match(editor.body, /Make current/); const pages = (await app.menu.store.get(archived)).manifest.pages.map((page) => page.id); const reorder = await app.inject({ method: 'POST', url: `/admin/sets/${archived}/reorder`, cookies: cookies(), headers: { 'x-csrf': csrf, accept: 'application/json' }, payload: { pageIds: [pages[1], pages[0]] }, }); assert.equal(reorder.statusCode, 409); }); }); describe('previewing a Draft', () => { it('renders the customer page with a banner, and serves its media to the session', async () => { const { id } = await newSet('bar', 'Bar Menu', '2026-06-26'); await upload(id, 'one.jpg'); const set = await app.menu.store.get(id); const page = set.manifest.pages[0]!; const preview = await app.inject({ method: 'GET', url: `/admin/sets/${id}/preview`, cookies: cookies(), }); assert.equal(preview.statusCode, 200); assert.match(preview.body, /Preview of a Draft/); assert.match(preview.body, /alt="Bar, page 1 of 1"/); assert.match(preview.body, /name="robots" content="noindex"/); assert.doesNotMatch(preview.body, /Older menus/); const media = `/media/${page.id}.${page.hash}.webp`; assert.equal((await app.inject({ method: 'GET', url: media })).statusCode, 404); assert.equal( (await app.inject({ method: 'GET', url: media, cookies: cookies() })).statusCode, 200, ); }); it('needs a session to preview', async () => { const { id } = await newSet(); const response = await app.inject({ method: 'GET', url: `/admin/sets/${id}/preview` }); assert.equal(response.statusCode, 303); }); });