/** * 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', 'home'); await write('404.html', 'missing'); await write('es/404/index.html', 'no encontrado'); await write('es/mints/index.html', 'casas'); await write('mints/index.html', 'mints'); 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/); });