Serve the customer-facing menus
Landing page, Section pages and archived sets rendered per request from the store, with the approved design: hand-written CSS, self-hosted Archivo, a copper pour line in the Section rail, an admin-only Thumbnail and a text page index. Media is keyed by Page id so URLs survive publishing, and a Draft's media needs a session. Includes the scrypt admin auth and the login throttle the media rule needs.
This commit is contained in:
1 parent
edea83228c
commit
9cb4021229
23 files changed
+2320
-6
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
LoginThrottle,
|
||||
createSessionToken,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
verifySessionToken,
|
||||
} from '../src/auth.ts';
|
||||
|
||||
const NOW = new Date('2026-05-01T06:00:00.000Z');
|
||||
|
||||
describe('the admin password', () => {
|
||||
it('verifies a correct password and rejects a wrong one', async () => {
|
||||
const stored = await hashPassword('correct horse battery staple');
|
||||
assert.match(stored, /^scrypt\$32768\$8\$1\$/);
|
||||
assert.equal(await verifyPassword('correct horse battery staple', stored), true);
|
||||
assert.equal(await verifyPassword('Correct horse battery staple', stored), false);
|
||||
assert.equal(await verifyPassword('', stored), false);
|
||||
});
|
||||
|
||||
it('salts every hash, so the same password never produces the same string', async () => {
|
||||
const first = await hashPassword('same password');
|
||||
const second = await hashPassword('same password');
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(await verifyPassword('same password', first), true);
|
||||
assert.equal(await verifyPassword('same password', second), true);
|
||||
});
|
||||
|
||||
it('rejects malformed stored values instead of throwing', async () => {
|
||||
for (const stored of ['', 'plaintext', 'scrypt$1$2$3', 'bcrypt$1$2$3$4$5', 'scrypt$x$8$1$aa$bb']) {
|
||||
assert.equal(await verifyPassword('anything', stored), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the session token', () => {
|
||||
const secret = 'a-long-random-secret';
|
||||
|
||||
it('accepts a fresh token', () => {
|
||||
const token = createSessionToken(secret, 60_000, NOW);
|
||||
assert.equal(verifySessionToken(token, secret, NOW), true);
|
||||
assert.equal(verifySessionToken(token, secret, new Date(NOW.getTime() + 59_000)), true);
|
||||
});
|
||||
|
||||
it('rejects an expired token', () => {
|
||||
const token = createSessionToken(secret, 60_000, NOW);
|
||||
assert.equal(verifySessionToken(token, secret, new Date(NOW.getTime() + 61_000)), false);
|
||||
});
|
||||
|
||||
it('rejects a token signed with another secret, a tampered expiry, and rubbish', () => {
|
||||
const token = createSessionToken(secret, 60_000, NOW);
|
||||
assert.equal(verifySessionToken(token, 'another-secret', NOW), false);
|
||||
const [, signature] = token.split('.');
|
||||
assert.equal(verifySessionToken(`${NOW.getTime() + 999_999}.${signature}`, secret, NOW), false);
|
||||
assert.equal(verifySessionToken(undefined, secret, NOW), false);
|
||||
assert.equal(verifySessionToken('', secret, NOW), false);
|
||||
assert.equal(verifySessionToken('no-dot', secret, NOW), false);
|
||||
assert.equal(verifySessionToken(`.${signature}`, secret, NOW), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the login throttle', () => {
|
||||
it('blocks the sixth failure inside the window and reports how long to wait', () => {
|
||||
let time = NOW.getTime();
|
||||
const throttle = new LoginThrottle({ maxFailures: 5, windowMs: 60_000, now: () => new Date(time) });
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
assert.equal(throttle.check('1.2.3.4').allowed, true);
|
||||
throttle.recordFailure('1.2.3.4');
|
||||
}
|
||||
|
||||
const blocked = throttle.check('1.2.3.4');
|
||||
assert.equal(blocked.allowed, false);
|
||||
assert.ok(blocked.retryAfterSeconds > 0);
|
||||
|
||||
assert.equal(throttle.check('5.6.7.8').allowed, true);
|
||||
|
||||
time += 61_000;
|
||||
assert.equal(throttle.check('1.2.3.4').allowed, true);
|
||||
});
|
||||
|
||||
it('clears failures on a successful login', () => {
|
||||
const throttle = new LoginThrottle({ maxFailures: 2, windowMs: 60_000, now: () => NOW });
|
||||
throttle.recordFailure('ip');
|
||||
throttle.recordFailure('ip');
|
||||
assert.equal(throttle.check('ip').allowed, false);
|
||||
throttle.reset('ip');
|
||||
assert.equal(throttle.check('ip').allowed, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { parseEnvFile } from '../src/env.ts';
|
||||
import { Html, escapeHtml, html, join } from '../src/render/html.ts';
|
||||
|
||||
describe('parseEnvFile', () => {
|
||||
it('reads keys, skips comments and blanks, and strips quotes', () => {
|
||||
const values = parseEnvFile(
|
||||
[
|
||||
'# a comment',
|
||||
'',
|
||||
'ADMIN_PASSWORD_HASH=scrypt$1$2$3$abc$def',
|
||||
'export SESSION_SECRET="quoted value"',
|
||||
"OTHER='single quoted'",
|
||||
'not a pair',
|
||||
'EMPTY=',
|
||||
].join('\n'),
|
||||
);
|
||||
assert.deepEqual(values, {
|
||||
ADMIN_PASSWORD_HASH: 'scrypt$1$2$3$abc$def',
|
||||
SESSION_SECRET: 'quoted value',
|
||||
OTHER: 'single quoted',
|
||||
EMPTY: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the html builder', () => {
|
||||
it('escapes interpolated values', () => {
|
||||
const rendered = html`<p title="${'a"b'}">${'<script>'}</p>`;
|
||||
assert.equal(rendered.value, '<p title="a"b"><script></p>');
|
||||
assert.equal(escapeHtml('&<>"\''), '&<>"'');
|
||||
});
|
||||
|
||||
it('leaves Html values alone, so composition is opt-in', () => {
|
||||
const inner = html`<em>${'safe & sound'}</em>`;
|
||||
const outer = html`<div>${inner}</div>`;
|
||||
assert.equal(outer.value, '<div><em>safe & sound</em></div>');
|
||||
assert.equal(Html.raw('<b>raw</b>').value, '<b>raw</b>');
|
||||
});
|
||||
|
||||
it('joins arrays and drops null, undefined and booleans', () => {
|
||||
const rendered = html`${[html`<li>a</li>`, html`<li>b</li>`]}${null}${undefined}${false}${0}`;
|
||||
assert.equal(rendered.value, '<li>a</li><li>b</li>0');
|
||||
assert.equal(join([1, 2, 3], '-').value, '1-2-3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { parseSiteConfig } from '../src/config.ts';
|
||||
import { createSessionToken } from '../src/auth.ts';
|
||||
import { buildApp } from '../src/server.ts';
|
||||
import { mediaUrls } from '../src/render/pages.ts';
|
||||
import { FakePipeline, pngBytes, tempDir, testClock } from './support/harness.ts';
|
||||
|
||||
const ASSETS = path.join(import.meta.dirname, '..', 'public');
|
||||
const SECRET = 'test-session-secret';
|
||||
|
||||
let dataDir: string;
|
||||
let app: FastifyInstance;
|
||||
|
||||
const CLOCK = testClock('2026-05-01T06:00:00.000Z');
|
||||
|
||||
function config(overrides: Record<string, unknown> = {}) {
|
||||
return parseSiteConfig({
|
||||
site: 'menu.example.test',
|
||||
brand: { name: 'Hops n Grains', tagline: 'Handcrafted Microbrewery' },
|
||||
sections: [
|
||||
{ slug: 'food', label: 'Food', order: 1 },
|
||||
{ slug: 'bar', label: 'Bar', order: 2 },
|
||||
{ slug: 'wine', label: 'Wine', order: 3 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function build(options: { config?: ReturnType<typeof config> } = {}): Promise<FastifyInstance> {
|
||||
const built = await buildApp({
|
||||
config: options.config ?? config(),
|
||||
dataDir,
|
||||
sessionSecret: SECRET,
|
||||
adminPasswordHash: null,
|
||||
assetsDir: ASSETS,
|
||||
secureCookies: false,
|
||||
logLevel: 'silent',
|
||||
pipeline: new FakePipeline(),
|
||||
now: CLOCK.now,
|
||||
});
|
||||
await built.ready();
|
||||
return built;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dataDir = await tempDir('routes');
|
||||
app = await build();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedPublishedBar() {
|
||||
const store = app.menu.store;
|
||||
const set = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
|
||||
const first = await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
|
||||
const second = await store.addPage(set.id, { data: pngBytes('two'), originalName: 'b.jpg' });
|
||||
const published = await store.publish(set.id);
|
||||
return { set, first, second, published };
|
||||
}
|
||||
|
||||
describe('an empty Site', () => {
|
||||
it('shows the empty state rather than a bare page', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.body, /The menus are being updated/);
|
||||
assert.match(response.headers['content-type'] ?? '', /text\/html/);
|
||||
});
|
||||
|
||||
it('404s a Section with no Current set, offering the Sections that exist', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/bar/' });
|
||||
assert.equal(response.statusCode, 404);
|
||||
assert.match(response.body, /That menu does not exist/);
|
||||
assert.match(response.body, /href="\/bar\/"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the landing page', () => {
|
||||
it('lists Sections that have a Current Menu Set, with page counts', async () => {
|
||||
await seedPublishedBar();
|
||||
const response = await app.inject({ method: 'GET', url: '/' });
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.body, /Hops n Grains/);
|
||||
assert.match(response.body, /Handcrafted Microbrewery/);
|
||||
assert.match(response.body, /<span class="row__label">Bar<\/span>/);
|
||||
assert.match(response.body, /2 pages/);
|
||||
assert.doesNotMatch(response.body, /row__label">Wine/);
|
||||
});
|
||||
|
||||
it('escapes a label that tries to inject markup', async () => {
|
||||
const store = app.menu.store;
|
||||
const set = await store.create('bar', { label: '<script>alert(1)</script>' });
|
||||
await store.addPage(set.id, { data: pngBytes('one'), originalName: 'a.jpg' });
|
||||
await store.publish(set.id);
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/bar/' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.doesNotMatch(response.body, /<script>alert\(1\)<\/script>/);
|
||||
assert.match(response.body, /<script>alert\(1\)<\/script>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Section page', () => {
|
||||
it('renders every Page with its variants, dimensions and alt text', async () => {
|
||||
const { first, second } = await seedPublishedBar();
|
||||
const response = await app.inject({ method: 'GET', url: '/bar/' });
|
||||
const body = response.body;
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(body, /Bar Menu/);
|
||||
assert.match(body, /until 26 June 2026/);
|
||||
assert.match(body, new RegExp(mediaUrls(first).webp));
|
||||
assert.match(body, new RegExp(mediaUrls(second).jpeg));
|
||||
assert.match(body, /alt="Bar, page 1 of 2"/);
|
||||
assert.match(body, /alt="Bar, page 2 of 2"/);
|
||||
assert.match(body, /width="3000"/);
|
||||
assert.match(body, /loading="eager"/);
|
||||
assert.match(body, /loading="lazy"/);
|
||||
assert.match(body, /id="page-1"/);
|
||||
assert.match(body, /href="#page-2"/);
|
||||
assert.doesNotMatch(body, /Older menus/);
|
||||
assert.doesNotMatch(body, /This is an older menu/);
|
||||
assert.match(body, /src="\/assets\/app\.js"/);
|
||||
});
|
||||
|
||||
it('serves both the trailing-slash and bare URLs', async () => {
|
||||
await seedPublishedBar();
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/bar/' })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/bar' })).statusCode, 200);
|
||||
});
|
||||
|
||||
it('404s an unknown Section', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/pizza/' });
|
||||
assert.equal(response.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an archived Menu Set', () => {
|
||||
it('is reachable at its date URL, marked as older, and does not list itself', async () => {
|
||||
const store = app.menu.store;
|
||||
const first = await store.create('bar', { label: 'Bar Menu', validUntil: '2026-06-26' });
|
||||
await store.addPage(first.id, { data: pngBytes('one'), originalName: 'a.jpg' });
|
||||
await store.publish(first.id);
|
||||
|
||||
CLOCK.advance(60_000);
|
||||
const second = await store.create('bar', { label: 'Bar Menu, revised' });
|
||||
await store.addPage(second.id, { data: pngBytes('two'), originalName: 'b.jpg' });
|
||||
await store.publish(second.id);
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/bar/2026-06-26/' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.body, /This is an older menu/);
|
||||
assert.match(response.body, /See the current menu/);
|
||||
assert.match(response.body, /href="\/bar\/"/);
|
||||
assert.match(response.body, /name="robots" content="noindex"/);
|
||||
assert.doesNotMatch(response.body, /Older menus/);
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: '/bar/' });
|
||||
assert.match(current.body, /Older menus/);
|
||||
assert.match(current.body, /href="\/bar\/2026-06-26\/"/);
|
||||
});
|
||||
|
||||
it('404s a slug that does not exist', async () => {
|
||||
await seedPublishedBar();
|
||||
const response = await app.inject({ method: 'GET', url: '/bar/1999-01-01/' });
|
||||
assert.equal(response.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media', () => {
|
||||
it('serves a Viewing image with an immutable cache and the right type', async () => {
|
||||
const { first } = await seedPublishedBar();
|
||||
const urls = mediaUrls(first);
|
||||
|
||||
const webp = await app.inject({ method: 'GET', url: urls.webp });
|
||||
assert.equal(webp.statusCode, 200);
|
||||
assert.equal(webp.headers['content-type'], 'image/webp');
|
||||
assert.match(String(webp.headers['cache-control']), /immutable/);
|
||||
assert.equal(webp.rawPayload.length > 0, true);
|
||||
|
||||
const jpeg = await app.inject({ method: 'GET', url: urls.jpeg });
|
||||
assert.equal(jpeg.statusCode, 200);
|
||||
assert.equal(jpeg.headers['content-type'], 'image/jpeg');
|
||||
|
||||
const thumb = await app.inject({ method: 'GET', url: urls.thumb });
|
||||
assert.equal(thumb.statusCode, 200);
|
||||
assert.equal(thumb.headers['content-type'], 'image/webp');
|
||||
});
|
||||
|
||||
it('404s a wrong hash, an unknown Page and a malformed name, and never serves an Original', async () => {
|
||||
const { first } = await seedPublishedBar();
|
||||
const wrongHash = `/media/${first.id}.${'0'.repeat(12)}.webp`;
|
||||
assert.equal((await app.inject({ method: 'GET', url: wrongHash })).statusCode, 404);
|
||||
assert.equal(
|
||||
(await app.inject({ method: 'GET', url: `/media/${first.id}.abcdefabcdef.webp` })).statusCode,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await app.inject({ method: 'GET', url: `/media/${first.id}.${first.hash}.gif` })).statusCode,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await app.inject({ method: 'GET', url: '/media/..%2F..%2Fetc%2Fpasswd' })).statusCode,
|
||||
404,
|
||||
);
|
||||
|
||||
// A JPEG source shares its extension with the Viewing image, so the bytes
|
||||
// prove which file was sent: the variant, never the Original beside it.
|
||||
const jpeg = await app.inject({ method: 'GET', url: mediaUrls(first).jpeg });
|
||||
assert.equal(jpeg.statusCode, 200);
|
||||
assert.equal(jpeg.rawPayload.toString(), `jpeg:${first.hash}`);
|
||||
assert.notEqual(jpeg.rawPayload.toString(), pngBytes('one').toString());
|
||||
});
|
||||
|
||||
it('hides a Draft Page from the public but serves it to a session holder', async () => {
|
||||
const store = app.menu.store;
|
||||
const draft = await store.create('bar', { label: 'Bar Menu' });
|
||||
const page = await store.addPage(draft.id, { data: pngBytes('one'), originalName: 'a.jpg' });
|
||||
const url = mediaUrls(page).webp;
|
||||
|
||||
const anonymous = await app.inject({ method: 'GET', url });
|
||||
assert.equal(anonymous.statusCode, 404);
|
||||
|
||||
const token = createSessionToken(SECRET, 60_000, new Date());
|
||||
const authed = await app.inject({
|
||||
method: 'GET',
|
||||
url,
|
||||
cookies: { menu_session: token },
|
||||
});
|
||||
assert.equal(authed.statusCode, 200);
|
||||
assert.match(String(authed.headers['cache-control']), /private/);
|
||||
|
||||
const tampered = await app.inject({
|
||||
method: 'GET',
|
||||
url,
|
||||
cookies: { menu_session: `${token}tampered` },
|
||||
});
|
||||
assert.equal(tampered.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('site furniture', () => {
|
||||
it('answers health, robots and assets', async () => {
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/healthz' })).body, 'ok');
|
||||
const robots = await app.inject({ method: 'GET', url: '/robots.txt' });
|
||||
assert.match(robots.body, /Disallow: \/admin/);
|
||||
const css = await app.inject({ method: 'GET', url: '/assets/styles.css' });
|
||||
assert.equal(css.statusCode, 200);
|
||||
assert.match(css.headers['content-type'] ?? '', /text\/css/);
|
||||
const font = await app.inject({ method: 'GET', url: '/assets/fonts/archivo-latin.woff2' });
|
||||
assert.equal(font.statusCode, 200);
|
||||
});
|
||||
|
||||
it('404s an unknown path with the not-found page', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/nope' });
|
||||
assert.equal(response.statusCode, 404);
|
||||
assert.match(response.body, /That menu does not exist/);
|
||||
});
|
||||
|
||||
it('carries the Site theme into the document', async () => {
|
||||
const custom = config({ theme: { accent: '#aa0088' } });
|
||||
const customApp = await build({ config: custom });
|
||||
const response = await customApp.inject({ method: 'GET', url: '/' });
|
||||
assert.match(response.body, /--accent: #aa0088/);
|
||||
await customApp.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user