Files
menu/test/identity.test.ts
T
tanshu 32eeed144f Let the owner change the name, the icon and the dates
Three things that needed a rebuild and a redeploy, or had no way in at all, now
have a Site identity panel in the admin: the restaurant's name and tagline, an
SVG icon upload, and a switch for whether a menu says when it was published.
They live in data/site.json and data/brand/icon.svg, extending ADR 0007: what
the owner changes is content, and the config is the seed plus the theme, which
still needs a rebuild.

An uploaded SVG is a document that would run with this origin's authority, so it
is reduced to shapes on the way in and served with default-src 'none'. The
archive list keeps its dates even when the switch is off, because those dates
are how one old menu is told from another.

Also fixes the live check's own trap: the app caches identity in memory, so the
tests drive it through the app rather than a second store instance, which would
be a second writer.
2026-09-22 23:11:40 +05:30

232 lines
9.0 KiB
TypeScript

import { strict as assert } from 'node:assert';
import { readFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { FastifyInstance } from 'fastify';
import { SESSION_COOKIE, createSessionToken, csrfToken } from '../src/auth.ts';
import { parseSiteConfig } from '../src/config.ts';
import { ICON_URL, IdentityError, IdentityStore, sanitiseIcon } from '../src/storage/identity.ts';
import { buildApp } from '../src/server.ts';
import { FakePipeline, tempDir } from './support/harness.ts';
const BRAND = { name: 'Hops n Grains', tagline: 'Handcrafted Microbrewery', logo: '/assets/favicon.svg' };
const SECRET = 'identity-test-secret';
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>';
let dataDir: string;
beforeEach(async () => {
dataDir = await tempDir('identity');
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
function config() {
return parseSiteConfig({
site: 'menu.example.test',
brand: BRAND,
sections: [{ slug: 'bar', label: 'Bar', order: 1 }],
});
}
async function loaded() {
const store = new IdentityStore({ dataDir, seed: { ...BRAND } });
await store.load();
return store;
}
describe('sanitiseIcon', () => {
it('strips script, event handlers and remote references but keeps the drawing', () => {
const dirty = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
<script>alert('x')</script>
<foreignObject><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject>
<a href="javascript:alert(2)"><rect width="4" height="4" onclick="alert(3)"/></a>
<style>rect{fill:red}</style>
</svg>`;
const clean = sanitiseIcon(dirty);
assert.doesNotMatch(clean, /script/i);
assert.doesNotMatch(clean, /onload|onclick/i);
assert.doesNotMatch(clean, /foreignObject/i);
assert.doesNotMatch(clean, /javascript:/i);
assert.doesNotMatch(clean, /<style/i);
assert.match(clean, /<rect width="4" height="4"\/>/);
});
});
describe('IdentityStore', () => {
it('seeds from the config the first time and writes the file', async () => {
const store = await loaded();
assert.deepEqual(store.view(), { name: BRAND.name, tagline: BRAND.tagline, showDates: true, hasIcon: false });
const file = JSON.parse(await readFile(path.join(dataDir, 'site.json'), 'utf8'));
assert.equal(file.name, BRAND.name);
});
it('changes the name, tagline and date switch without touching the config', async () => {
const store = await loaded();
const updated = await store.update({ name: ' Hops and Grains ', tagline: '', showDates: false });
assert.deepEqual(updated, { name: 'Hops and Grains', tagline: null, showDates: false, hasIcon: false });
const applied = store.apply(config());
assert.equal(applied.brand.name, 'Hops and Grains');
assert.equal(applied.brand.tagline, null);
assert.equal(applied.display.showDates, false);
assert.equal(config().brand.name, 'Hops n Grains', 'the config is only the seed');
});
it('rejects an empty name', async () => {
const store = await loaded();
await assert.rejects(() => store.update({ name: ' ' }), IdentityError);
});
it('takes an uploaded icon and points the Site at it', async () => {
const store = await loaded();
await store.putIcon(Buffer.from(SVG));
assert.equal(store.view().hasIcon, true);
assert.equal(store.apply(config()).brand.logo, ICON_URL);
assert.match(await readFile(store.iconPath(), 'utf8'), /<svg/);
});
it('refuses anything that is not a usable SVG', async () => {
const store = await loaded();
await assert.rejects(() => store.putIcon(Buffer.alloc(0)), IdentityError);
await assert.rejects(() => store.putIcon(Buffer.from('a PNG, honest')), IdentityError);
await assert.rejects(() => store.putIcon(Buffer.from('<svg>')), IdentityError);
await assert.rejects(() => store.putIcon(Buffer.alloc(300 * 1024, 32)), IdentityError);
assert.equal(store.view().hasIcon, false);
});
it('forgets the icon on request and falls back to the bundled mark', async () => {
const store = await loaded();
await store.putIcon(Buffer.from(SVG));
await store.removeIcon();
assert.equal(store.view().hasIcon, false);
assert.equal(store.apply(config()).brand.logo, '/assets/favicon.svg');
});
});
describe('identity through the app', () => {
let app: FastifyInstance;
let session: string;
let csrf: string;
beforeEach(async () => {
app = await buildApp({
config: config(),
dataDir,
sessionSecret: SECRET,
adminPasswordHash: null,
assetsDir: path.join(import.meta.dirname, '..', 'public'),
secureCookies: false,
logLevel: 'silent',
pipeline: new FakePipeline(),
});
await app.ready();
session = createSessionToken(SECRET, 60 * 60 * 1000, new Date());
csrf = csrfToken(session, SECRET);
});
afterEach(async () => {
await app.close();
});
const cookies = () => ({ [SESSION_COOKIE]: session });
async function iconBody(token: string, filename: string, data: Buffer) {
const boundary = '----iconboundary';
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="icon"; filename="${filename}"\r\nContent-Type: image/svg+xml\r\n\r\n`,
),
data,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return app.inject({
method: 'POST',
url: '/admin/identity/icon',
cookies: cookies(),
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
referer: '/admin',
},
payload,
});
}
it('renames the restaurant and hides the dates on the customer pages', async () => {
await app.menu.identity.update({ name: 'Hops and Grains', showDates: false });
const landing = await app.inject({ method: 'GET', url: '/' });
assert.match(landing.body, /Hops and Grains/);
const barSection = await app.inject({ method: 'GET', url: '/bar/' });
assert.equal(barSection.statusCode, 404, 'no menu published yet in this fixture');
// With a menu published, the heading loses its date line but keeps its name.
const set = await app.menu.store.create('bar', { label: 'Bar Menu' });
await app.menu.store.addPage(set.id, { data: Buffer.from('x'), originalName: 'a.jpg' });
await app.menu.store.publish(set.id);
const published = await app.inject({ method: 'GET', url: '/bar/' });
assert.match(published.body, /section-heading__label/);
assert.doesNotMatch(published.body, /section-heading__date/);
});
it('shows the date when the switch is on', async () => {
const set = await app.menu.store.create('bar', { label: 'Bar Menu' });
await app.menu.store.addPage(set.id, { data: Buffer.from('x'), originalName: 'a.jpg' });
await app.menu.store.publish(set.id);
const published = await app.inject({ method: 'GET', url: '/bar/' });
assert.match(published.body, /section-heading__date/);
});
it('accepts an SVG icon, serves it with a locked-down policy, and drops it again', async () => {
assert.equal((await app.inject({ method: 'GET', url: ICON_URL })).statusCode, 404);
const uploaded = await iconBody(csrf, 'mark.svg', Buffer.from(SVG));
assert.equal(uploaded.statusCode, 303);
const icon = await app.inject({ method: 'GET', url: ICON_URL });
assert.equal(icon.statusCode, 200);
assert.match(String(icon.headers['content-type']), /image\/svg\+xml/);
assert.match(String(icon.headers['content-security-policy']), /default-src 'none'/);
assert.match(icon.body, /<svg/);
const landing = await app.inject({ method: 'GET', url: '/' });
assert.match(landing.body, new RegExp(ICON_URL.replace('/', '\\/')));
const removed = await app.inject({
method: 'POST',
url: '/admin/identity/icon/delete',
cookies: cookies(),
headers: { 'x-csrf': csrf, referer: '/admin' },
});
assert.equal(removed.statusCode, 303);
assert.equal((await app.inject({ method: 'GET', url: ICON_URL })).statusCode, 404);
});
it('refuses an icon that is not an SVG, and refuses one with no token', async () => {
const notSvg = await iconBody(csrf, 'photo.jpg', Buffer.from('just a jpeg'));
assert.match(String(notSvg.headers['location']), /error=/);
assert.equal((await app.inject({ method: 'GET', url: ICON_URL })).statusCode, 404);
const noToken = await iconBody('nonsense', 'mark.svg', Buffer.from(SVG));
assert.equal(noToken.statusCode, 403);
});
it('needs a token to change the identity', async () => {
const response = await app.inject({
method: 'POST',
url: '/admin/identity',
cookies: cookies(),
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: 'name=Hijacked',
});
assert.equal(response.statusCode, 403);
assert.equal(app.menu.identity.view().name, BRAND.name);
});
});