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