Make the admin's buttons work in a browser

Every mutating form now carries the CSRF token as a hidden field, and the
upload route accepts it as a multipart field as well as a header. Driving the
real UI in Firefox found what the header-only tests could not: Create Draft,
Save, Publish, Delete and the up/down buttons all posted without a token and
were rejected. Adds npm run check-site, which verifies a running Site from the
outside and exits non-zero so it can gate a cutover.
This commit is contained in:
tanshu committed 2026-09-22 21:32:52 +05:30
1 parent f89bd63685
commit af083415a7
6 files changed
+299 -4

No files matched your search

+136
View File
@@ -491,6 +491,142 @@ describe('publishing, restoring and deleting', () => {
});
});
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');