Files
menu/test/identity.test.ts
tanshu e64f65642f Add ESLint and Prettier
Prettier owns formatting: .prettierrc.json, format and format:check scripts, and
a one-off pass over the repo so the check is green from the start. Embedded
language formatting is off deliberately - the renderers build HTML in html
tagged templates and letting Prettier reflow those would change the markup
customers receive. docs/ is excluded because it is hand-wrapped prose, and shell
scripts have no Prettier parser.

ESLint covers the JavaScript: the two browser scripts, the build and check
scripts, and its own config. no-var is scoped out for the browser scripts on
purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only
show up in a browser.

ESLint cannot lint the TypeScript here: typescript@7 is the native port with no
JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it
refuses to install and forcing it through would leave a parser that cannot read
the compiler. tsc --noEmit stays the TypeScript gate; README records the two
ways to change that.

lint and format:check now run in deploy.sh, and VS Code formats on save with
Prettier and surfaces ESLint findings.
2026-09-22 23:15:08 +05:30

253 lines
9.1 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);
});
});