// The editor's page and bundle from F (editorFiles.ts, precompress.ts): // which file a path names and which it never names, what goes out packed, // how long a browser keeps it, and that F's own routes stay F's. import { randomBytes } from 'node:crypto'; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createServer, request as httpRequest, type IncomingHttpHeaders, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { brotliDecompressSync, gunzipSync } from 'node:zlib'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { accepts, ASSET_CACHE, editorFiles, fileOf, PAGE_CACHE, PostgresSessions, PostgresStore, precompress } from '../src/index.js'; import { testDatabase, type TestDatabase } from './database.js'; import { startTestServer } from './harness.js'; const PAGE = 'Filius'; const SCRIPT = `export const devices = [${Array.from({ length: 400 }, (_, index) => `'device ${index}'`).join(', ')}];\n`; /** A built editor as Vite lays it, beside a file it must never hand out. */ function builtEditor(): string { const outside = mkdtempSync(join(tmpdir(), 'filius-editor-files-')); writeFileSync(join(outside, 'secret.txt'), 'not for the browser'); const folder = join(outside, 'editor'); mkdirSync(join(folder, 'assets'), { recursive: true }); writeFileSync(join(folder, 'index.html'), PAGE); writeFileSync(join(folder, 'assets.json'), '{}'); writeFileSync(join(folder, 'assets', 'index-AbC123.js'), SCRIPT); writeFileSync(join(folder, 'assets', 'index-AbC123.js.br'), 'packed with brotli'); writeFileSync(join(folder, 'assets', 'index-AbC123.js.gz'), 'packed with gzip'); writeFileSync(join(folder, 'assets', 'router-Xy9.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); writeFileSync(join(folder, 'assets', 'Netz-Ab1.fls'), 'PK'); writeFileSync(join(folder, 'assets', 'style-Q1.css'), 'a{color:red}'); writeFileSync(join(folder, 'assets', 'style-Q1.css.gz'), 'packed with gzip only'); return folder; } interface Answer { readonly status: number; readonly headers: IncomingHttpHeaders; readonly body: Buffer; } /** A request as it leaves a browser, the body as it arrives (not unpacked). */ function ask(base: string, path: string, headers: Record = {}, method = 'GET'): Promise { return new Promise((resolve, reject) => { const sent = httpRequest(`${base}${path}`, { method, headers }, (response) => { const parts: Buffer[] = []; response.on('data', (part: Buffer) => parts.push(part)); response.on('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body: Buffer.concat(parts) })); }); sent.on('error', reject); sent.end(); }); } describe('fileOf', () => { const root = '/srv/editor'; it('names the page for the root and a file below for its path', () => { expect(fileOf(root, '/')).toBe('/srv/editor/index.html'); expect(fileOf(root, '/index.html')).toBe('/srv/editor/index.html'); expect(fileOf(root, '/assets/index-AbC123.js')).toBe('/srv/editor/assets/index-AbC123.js'); expect(fileOf(root, '/assets/Gro%C3%9Fes%20Netz.fls')).toBe('/srv/editor/assets/Großes Netz.fls'); }); it('resolves a way round inside the folder to where it leads', () => { expect(fileOf(root, '/assets/../index.html')).toBe('/srv/editor/index.html'); expect(fileOf(root, '/./assets//index-AbC123.js')).toBe('/srv/editor/assets/index-AbC123.js'); }); it.each([['/..'], ['/../secret.txt'], ['/%2e%2e/secret.txt'], ['/assets/..%2f..%2fsecret.txt'], ['/assets/../../editor-old/index.html'], ['/%E0%A4%A']])('names nothing for %s', (path) => { expect(fileOf(root, path)).toBeUndefined(); }); }); describe('accepts', () => { it.each([ ['gzip, deflate, br, zstd', 'br', true], ['gzip, deflate, br, zstd', 'gzip', true], ['gzip;q=0.5, br;q=1.0', 'br', true], ['BR', 'br', true], ['gzip, br;q=0', 'br', false], ['br; q=0.000', 'br', false], ['br;q=0.01', 'br', true], ['gzip', 'br', false], ['', 'gzip', false], ['xbr, brx', 'br', false], ])('%j takes %s: %s', (header, name, taken) => { expect(accepts(header, name)).toBe(taken); }); }); describe('editorFiles', () => { let server: Server; let base: string; beforeAll(async () => { const files = editorFiles(builtEditor()); server = createServer((request, response) => { void files(request, response, new URL(request.url ?? '/', 'http://filius').pathname).then((answered) => { if (!answered) response.writeHead(404).end(); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(() => new Promise((resolve) => server.close(() => resolve()))); it('hands out the page, asked for again at every opening', async () => { for (const path of ['/', '/?project=1b4e28ba-2fa1-41d2-883f-0016d3cca427', '/index.html']) { const answer = await ask(base, path); expect(answer.status).toBe(200); expect(answer.body.toString()).toBe(PAGE); expect(answer.headers['content-type']).toBe('text/html; charset=utf-8'); expect(answer.headers['cache-control']).toBe(PAGE_CACHE); expect(answer.headers['x-content-type-options']).toBe('nosniff'); } }); it('keeps a file of the bundle a year: its name changes with its content', async () => { const answer = await ask(base, '/assets/router-Xy9.png'); expect(answer.headers['cache-control']).toBe(ASSET_CACHE); expect(answer.headers['content-type']).toBe('image/png'); expect(answer.headers['content-length']).toBe('4'); }); it('keeps only what lies in assets/ a year, not a neighbour of a like name', async () => { expect((await ask(base, '/assets.json')).headers['cache-control']).toBe(PAGE_CACHE); }); it('names what it does not know a series of bytes', async () => { expect((await ask(base, '/assets/Netz-Ab1.fls')).headers['content-type']).toBe('application/octet-stream'); }); it('sends the packed file the browser takes, the better first', async () => { const brotli = await ask(base, '/assets/index-AbC123.js', { 'accept-encoding': 'gzip, deflate, br, zstd' }); expect([brotli.headers['content-encoding'], brotli.body.toString()]).toEqual(['br', 'packed with brotli']); expect(brotli.headers['content-length']).toBe(String('packed with brotli'.length)); expect(brotli.headers['content-type']).toBe('text/javascript; charset=utf-8'); expect(brotli.headers.vary).toBe('Accept-Encoding'); const gzip = await ask(base, '/assets/index-AbC123.js', { 'accept-encoding': 'gzip, br;q=0' }); expect([gzip.headers['content-encoding'], gzip.body.toString()]).toEqual(['gzip', 'packed with gzip']); const plain = await ask(base, '/assets/index-AbC123.js'); expect([plain.headers['content-encoding'], plain.body.toString()]).toEqual([undefined, SCRIPT]); }); it('sends the next packed file the browser takes when the better one is missing', async () => { const answer = await ask(base, '/assets/style-Q1.css', { 'accept-encoding': 'br, gzip' }); expect([answer.headers['content-encoding'], answer.body.toString()]).toEqual(['gzip', 'packed with gzip only']); }); it('sends a file without a packed one as it is, whatever the browser takes', async () => { const answer = await ask(base, '/index.html', { 'accept-encoding': 'br, gzip' }); expect([answer.headers['content-encoding'], answer.body.toString()]).toEqual([undefined, PAGE]); }); it('answers HEAD with the headers alone', async () => { const answer = await ask(base, '/', {}, 'HEAD'); expect(answer.status).toBe(200); expect(answer.headers['content-length']).toBe(String(PAGE.length)); expect(answer.body.length).toBe(0); }); it.each([ ['GET', '/../secret.txt'], ['GET', '/%2e%2e/secret.txt'], ['GET', '/..%5csecret.txt'], ['GET', '/index.html%00'], ['GET', '/assets'], ['GET', '/assets/'], ['GET', '/missing.js'], ['POST', '/'], ['DELETE', '/index.html'], ])('leaves %s %s to the caller', async (method, path) => { expect((await ask(base, path, {}, method)).status).toBe(404); }); }); describe('precompress', () => { it('lays a smaller .br and .gz beside each compressible file, below too, and nothing else', async () => { const folder = mkdtempSync(join(tmpdir(), 'filius-precompress-')); mkdirSync(join(folder, 'assets')); writeFileSync(join(folder, 'index.html'), `${'

Filius

'.repeat(50)}`); writeFileSync(join(folder, 'assets', 'index.js'), SCRIPT); writeFileSync(join(folder, 'assets', 'tiny.css'), 'a{}'); // A picture would pack well here; it is left alone for its kind, not its size. writeFileSync(join(folder, 'assets', 'router.png'), SCRIPT); writeFileSync(join(folder, 'assets', 'packed.js'), randomBytes(4096)); expect(await precompress(folder)).toBe(4); expect(readdirSync(folder).sort()).toEqual(['assets', 'index.html', 'index.html.br', 'index.html.gz']); expect(readdirSync(join(folder, 'assets')).sort()).toEqual(['index.js', 'index.js.br', 'index.js.gz', 'packed.js', 'router.png', 'tiny.css']); const script = join(folder, 'assets', 'index.js'); expect(brotliDecompressSync(readFileSync(`${script}.br`)).toString()).toBe(SCRIPT); expect(gunzipSync(readFileSync(`${script}.gz`)).toString()).toBe(SCRIPT); expect(readFileSync(`${script}.br`).length).toBeLessThan(readFileSync(`${script}.gz`).length); // A second run packs the same files again, not the packed ones. expect(await precompress(folder)).toBe(4); expect(readdirSync(join(folder, 'assets'))).toHaveLength(6); }); }); describe('the editor on server F', () => { let database: TestDatabase; let store: PostgresStore; let sessions: PostgresSessions; beforeAll(async () => { database = await testDatabase(); store = PostgresStore.connect(database.url); sessions = new PostgresSessions(store.pool); await store.migrate(); await sessions.migrate(); }, 60_000); afterAll(async () => { await store?.close(); await database?.stop(); }); it("comes from F's host, and F's own routes stay F's", async () => { const folder = builtEditor(); writeFileSync(join(folder, 'healthz'), 'a file named like a route'); mkdirSync(join(folder, 'svc')); writeFileSync(join(folder, 'svc', 'sessions'), 'a file named like a route'); const server = await startTestServer(store, sessions, { editor: editorFiles(folder) }); try { expect((await ask(server.base, '/')).body.toString()).toBe(PAGE); const health = await ask(server.base, '/healthz'); expect(JSON.parse(health.body.toString())).toMatchObject({ status: 'ok' }); expect((await ask(server.base, '/svc/sessions')).status).toBe(403); expect(JSON.parse((await ask(server.base, '/missing.js')).body.toString())).toEqual({ error: 'notFound' }); } finally { await server.close(); } }); it('lets a browser go that leaves mid-file, without counting it a failure', async () => { const folder = builtEditor(); writeFileSync(join(folder, 'assets', 'large-Zz1.js'), randomBytes(16 * 1024 * 1024)); const server = await startTestServer(store, sessions, { editor: editorFiles(folder) }); try { await new Promise((resolve) => { const sent = httpRequest(`${server.base}/assets/large-Zz1.js`, (response) => response.once('data', () => sent.destroy())); sent.on('close', () => resolve()); sent.on('error', () => undefined); sent.end(); }); await new Promise((resolve) => setTimeout(resolve, 200)); expect(server.log.lines.filter((line) => line.event === 'http.failed')).toEqual([]); expect((await ask(server.base, '/')).body.toString()).toBe(PAGE); } finally { await server.close(); } }); it('answers only its own routes without an editor', async () => { const server = await startTestServer(store, sessions); try { const answer = await ask(server.base, '/'); expect([answer.status, JSON.parse(answer.body.toString())]).toEqual([404, { error: 'notFound' }]); } finally { await server.close(); } }); });