Files
menu/test/admin.test.ts
T
tanshu 58ecfa0dc3 Publish menus from the browser instead of scp
The admin signs in with the shared password, creates a Menu Set, drops photos
into it (shrunk in the browser first), reorders Pages by dragging or with
up/down buttons, previews the Draft exactly as customers would see it, and
publishes it over the previous set. Restore and delete close the loop. Mutations
need a session, a same-origin request and a CSRF token derived from the session;
a Draft's media is served only to a session holder.
2026-09-22 21:22:26 +05:30

526 lines
17 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): 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 },
],
}),
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('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('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);
});
});