Files
menu/test/admin.test.ts
T
tanshu 7bf67cc299 Make reordering work on the page you are actually looking at
Two defects, both found by driving the real editor in Firefox:

1. The editor drew grab handles with a grab cursor on sets it refuses to
   reorder, so the live Food menu looked draggable and did nothing. Handles and
   the move buttons are now rendered only when reordering is possible, and the
   notice says what can still be done.

2. Reordering was saved only from dragend. Browsers suppress mouse events during
   a drag and dragend is not guaranteed to arrive, which left the rows visibly
   reordered while the server kept the old order — the drag looked like it
   worked and then reverted on reload. The order now commits from dragover after
   a pause in the drag, with drop/dragend/mouse events as faster paths, deduped
   against the last confirmed order and reported in the page.

To make (1) coherent the Current set may now be reordered while its Pages stay
fixed: order is presentation, media URLs are content-hashed and pages are
rendered per request, so nothing stale can be served (ADR 0006). Archives stay
frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like
this one is not masked by a stale admin.js.

scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it
is the only automated check that reaches this path.
2026-09-22 22:01:52 +05:30

786 lines
27 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('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);
});
});