Files
michilisandCursor 79a115be38 Serve the prerendered site from Node instead of nginx root.
Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 06:27:27 +02:00

167 lines
6.6 KiB
JavaScript

/**
* What the production server has to get right that a static file server does not.
*
* The site was served by nginx pointing a `root` at dist until this process took over,
* and the rules that lived in that config are the ones worth pinning down here: a miss
* has to answer 404 rather than 200 with a 404-shaped page, a miss under a locale has
* to stay in that locale, hashed assets have to be immutable and markup must not be,
* and nothing outside the root may be readable however the path is spelled.
*
* A fixture tree rather than dist/: these are assertions about the server, and running
* them should not require a build.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'cashumints-web-'));
const write = async (rel, body) => {
await fs.mkdir(path.join(root, path.dirname(rel)), { recursive: true });
await fs.writeFile(path.join(root, rel), body);
};
await write('index.html', '<!doctype html><html lang="en">home</html>');
await write('404.html', '<!doctype html><html lang="en">missing</html>');
await write('es/404/index.html', '<!doctype html><html lang="es">no encontrado</html>');
await write('es/mints/index.html', '<!doctype html><html lang="es">casas</html>');
await write('mints/index.html', '<!doctype html><html lang="en">mints</html>');
await write('robots.txt', 'User-agent: *\n');
await write('_astro/app.abc123.js', 'export default 1;\n');
await write('og/default.png', 'not really a png');
await write('og/mint.abc123.png', 'not really a png either');
// ROOT is read from the environment once, at import.
process.env.WEB_ROOT = root;
const { createServer, safePath, candidates, cacheControl } = await import('../server.mjs');
const server = createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
test.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fs.rm(root, { recursive: true, force: true });
});
const get = (p, init) => fetch(`${base}${p}`, init);
test('serves the index at the root', async () => {
const res = await get('/');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'text/html; charset=utf-8');
assert.match(await res.text(), /home/);
});
test('resolves directory routes with and without a trailing slash', async () => {
for (const p of ['/mints', '/mints/']) {
const res = await get(p);
assert.equal(res.status, 200, p);
assert.match(await res.text(), /mints/);
}
});
test('a miss is a 404, not a 200 carrying the 404 page', async () => {
const res = await get('/nope');
assert.equal(res.status, 404);
assert.match(await res.text(), /missing/);
});
test('a miss under a locale stays in that locale', async () => {
const res = await get('/es/nope');
assert.equal(res.status, 404);
assert.match(await res.text(), /no encontrado/);
});
test('a miss under a prefix that is not a locale falls back to English', async () => {
const res = await get('/mint/does-not-exist');
assert.equal(res.status, 404);
assert.match(await res.text(), /missing/);
});
test('hashed assets are immutable and markup is not', async () => {
const asset = await get('/_astro/app.abc123.js');
assert.equal(asset.headers.get('cache-control'), 'public, max-age=31536000, immutable');
const page = await get('/');
assert.equal(page.headers.get('cache-control'), 'public, max-age=0, must-revalidate');
});
test('the one unhashed card is not cached for a year', async () => {
const shared = await get('/og/default.png');
assert.equal(shared.headers.get('cache-control'), 'public, max-age=3600');
const hashed = await get('/og/mint.abc123.png');
assert.equal(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
});
test('a matching ETag revalidates into a bodiless 304', async () => {
const first = await get('/');
const etag = first.headers.get('etag');
assert.ok(etag);
const second = await get('/', { headers: { 'If-None-Match': etag } });
assert.equal(second.status, 304);
assert.equal(await second.text(), '');
});
test('HEAD answers with the headers and no body', async () => {
const res = await get('/', { method: 'HEAD' });
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-length'), String((await fs.stat(path.join(root, 'index.html'))).size));
assert.equal(await res.text(), '');
});
test('anything but GET and HEAD is refused', async () => {
const res = await get('/', { method: 'POST' });
assert.equal(res.status, 405);
assert.equal(res.headers.get('allow'), 'GET, HEAD');
});
test('nothing outside the root is readable', async () => {
await fs.writeFile(path.join(root, '..', 'cashumints-secret.txt'), 'secret');
for (const p of ['/../cashumints-secret.txt', '/%2e%2e/cashumints-secret.txt', '/mints/../../cashumints-secret.txt']) {
const res = await get(p);
assert.equal(res.status, 404, p);
assert.doesNotMatch(await res.text(), /secret/, p);
}
await fs.rm(path.join(root, '..', 'cashumints-secret.txt'), { force: true });
});
test('dotfiles are refused rather than looked up', async () => {
const res = await get('/.env');
assert.equal(res.status, 400);
});
test('every response carries the baseline security headers', async () => {
const res = await get('/');
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
assert.equal(res.headers.get('referrer-policy'), 'strict-origin-when-cross-origin');
assert.equal(res.headers.get('x-frame-options'), 'DENY');
});
test('safePath refuses what it should and keeps what it should', () => {
assert.deepEqual(safePath('/mints/'), ['mints']);
assert.deepEqual(safePath('/'), []);
assert.equal(safePath('/a\0b'), null);
assert.equal(safePath('/.git/config'), null);
// Normalised away rather than escaping: the lookup stays inside the root.
assert.deepEqual(safePath('/../../etc/passwd'), ['etc', 'passwd']);
});
test('candidates cover the shapes a static build emits', () => {
assert.deepEqual(candidates([]), ['index.html']);
assert.deepEqual(candidates(['mints']), ['mints', 'mints/index.html', 'mints.html']);
});
test('cacheControl is decided by path and extension', () => {
assert.match(cacheControl('/mints', '.html'), /must-revalidate/);
assert.match(cacheControl('/_astro/x.js', '.js'), /immutable/);
assert.match(cacheControl('/robots.txt', '.txt'), /max-age=3600/);
assert.match(cacheControl('/favicon.ico', '.ico'), /max-age=604800/);
});