A file past the size limit makes the multipart stream itself throw, which escaped the per-file catch and reached the error handler: the phone that just sent a 35 MB photo got 'Something went wrong'. Catch the limit errors around the part loop, say which limit was hit, and report it once rather than twice. Also asserts that derivatives carry no EXIF while the Original keeps the uploaded bytes.
708 lines
24 KiB
TypeScript
708 lines
24 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('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);
|
|
});
|
|
});
|