Categories were baked into config/<site>.site.json, so adding one meant a rebuild and a redeploy. They are now data: data/sections.json, seeded from the config the first time, and managed in the admin (add, rename, reorder, remove). A category's web address is fixed when it is created because that is what a QR code points at, and a category cannot be removed while it still holds menus. The landing and the top bar were the ugliest parts. The landing was a column of hairlines with tiny meta — a broadsheet, and the paper that makes this app worth looking at was nowhere on it. It is now a shelf: each category is a row whose right edge is a sliver of that menu's first page, bleeding off the screen, with the shelf edge in brass and the name in heavy tight Archivo. The bar was a row of teal pills that would not survive more categories: it now names the menu you are reading and opens a sheet listing the rest, each with its own paper sliver, and the current one is the only teal thing on screen. The sheet is a link to the landing page, so navigating still works without the script. Also fixes the category manager's own layout, which I shipped unstyled, and the markup contract the route tests described.
908 lines
32 KiB
TypeScript
908 lines
32 KiB
TypeScript
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<FastifyInstance> {
|
|
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<string, string>) => ({
|
|
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(/<form\b[^>]*>[\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('managing categories', () => {
|
|
const form = (fields: Record<string, string>, referer?: string) => ({
|
|
headers: {
|
|
'content-type': 'application/x-www-form-urlencoded',
|
|
...(referer === undefined ? {} : { referer }),
|
|
},
|
|
payload: new URLSearchParams(fields).toString(),
|
|
});
|
|
|
|
async function token(): Promise<string> {
|
|
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
|
const match = dashboard.body.match(/name="csrf" value="([^"]+)"/);
|
|
assert.ok(match, 'the dashboard carries no CSRF field');
|
|
return match[1]!;
|
|
}
|
|
|
|
const slugs = () => app.menu.sections.list().map((section) => section.slug);
|
|
|
|
it('lists the categories with the web address each one keeps', async () => {
|
|
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
|
assert.match(dashboard.body, /Categories \(2\)/);
|
|
assert.match(dashboard.body, /\/food\//);
|
|
assert.match(dashboard.body, /\/bar\//);
|
|
});
|
|
|
|
it('adds a category, slugged from its name, and customers do not see it until it has a menu', async () => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/sections',
|
|
cookies: cookies(),
|
|
...form({ csrf: await token(), label: 'Straight Drinks' }),
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded', referer: '/admin' },
|
|
});
|
|
assert.equal(response.statusCode, 303);
|
|
assert.match(String(response.headers['location']), /notice=/);
|
|
assert.deepEqual(slugs(), ['food', 'bar', 'straight-drinks']);
|
|
|
|
const landing = await app.inject({ method: 'GET', url: '/' });
|
|
assert.doesNotMatch(landing.body, /straight-drinks/);
|
|
assert.equal((await app.inject({ method: 'GET', url: '/straight-drinks/' })).statusCode, 404);
|
|
});
|
|
|
|
it('renames a category without moving the web address', async () => {
|
|
const referer = '/admin';
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/sections/bar/rename',
|
|
cookies: cookies(),
|
|
...form({ csrf: await token(), label: 'Bar & Cocktails' }, referer),
|
|
});
|
|
assert.equal(response.statusCode, 303);
|
|
assert.equal(app.menu.sections.bySlug('bar')?.label, 'Bar & Cocktails');
|
|
assert.equal(app.menu.sections.bySlug('bar-and-cocktails'), undefined);
|
|
});
|
|
|
|
it('reorders categories', async () => {
|
|
assert.deepEqual(slugs(), ['food', 'bar']);
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/sections/bar/move',
|
|
cookies: cookies(),
|
|
...form({ csrf: await token(), direction: 'up' }, '/admin'),
|
|
});
|
|
assert.equal(response.statusCode, 303);
|
|
assert.deepEqual(slugs(), ['bar', 'food']);
|
|
|
|
const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() });
|
|
assert.ok(
|
|
dashboard.body.indexOf('value="Bar"') < dashboard.body.indexOf('value="Food"'),
|
|
'the manager lists them in the new order',
|
|
);
|
|
});
|
|
|
|
it('refuses to remove a category that still holds menus, then allows it once empty', async () => {
|
|
const { id } = await newSet('bar', 'Bar Menu');
|
|
await upload(id, 'one.jpg');
|
|
|
|
const blocked = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/sections/bar/delete',
|
|
cookies: cookies(),
|
|
...form({ csrf: await token() }, '/admin'),
|
|
});
|
|
assert.equal(blocked.statusCode, 303);
|
|
const blockedUrl = String(blocked.headers['location']);
|
|
assert.match(blockedUrl, /error=/);
|
|
assert.deepEqual(slugs(), ['food', 'bar']);
|
|
|
|
const dashboard = await app.inject({ method: 'GET', url: blockedUrl, cookies: cookies() });
|
|
assert.match(dashboard.body, /still holds 1 menu set/);
|
|
|
|
await app.inject({
|
|
method: 'POST',
|
|
url: `/admin/sets/${id}/delete`,
|
|
cookies: cookies(),
|
|
headers: { 'x-csrf': csrf },
|
|
payload: { confirm: 'yes' },
|
|
});
|
|
|
|
const removed = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/sections/bar/delete',
|
|
cookies: cookies(),
|
|
...form({ csrf: await token() }, '/admin'),
|
|
});
|
|
assert.match(String(removed.headers['location']), /notice=/);
|
|
assert.deepEqual(slugs(), ['food']);
|
|
});
|
|
|
|
it('needs a token to change the categories', async () => {
|
|
const response = await app.inject({
|
|
method: 'POST',
|
|
url: '/admin/sections',
|
|
cookies: cookies(),
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
payload: 'label=Desserts',
|
|
});
|
|
assert.equal(response.statusCode, 403);
|
|
assert.deepEqual(slugs(), ['food', 'bar']);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|