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:
1 parent
f89bd63685
commit
af083415a7
6 files changed
+299
-4
No files matched your search
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user