Avoids www-data traversing the cashumints tree and keeps rebuilds from blanking a live root. Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
6.6 KiB
JavaScript
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/);
|
|
});
|