import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { connect } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createServer } from './server'; const INDEX = 'fixture app'; const SECRET = 'top-secret-outside-the-root'; const JS = 'main-ABC123.js'; const IMMUTABLE = 'public, max-age=31536000'; let workdir: string; let server: ReturnType; let emptyServer: ReturnType; // fetch() normalizes dot segments, so traversal needs an unnormalized request line. function rawGet(port: number, target: string): Promise<{ status: number; text: string }> { return new Promise((resolve, reject) => { let text = ''; const socket = connect(port, '127.0.0.1', () => socket.write(`GET ${target} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n`), ); socket.on('data', (chunk) => (text += chunk)); socket.on('end', () => resolve({ status: Number(text.split(' ')[1]), text })); socket.on('error', reject); }); } beforeAll(() => { workdir = mkdtempSync(join(tmpdir(), 'vac-server-')); const root = join(workdir, 'browser'); mkdirSync(join(root, 'assets'), { recursive: true }); writeFileSync(join(root, 'index.html'), INDEX); writeFileSync(join(root, JS), 'console.log("app");'); writeFileSync(join(root, 'styles-DEF456.css'), 'body{margin:0}'); writeFileSync(join(root, 'assets', 'data.json'), '{"nested":true}'); writeFileSync(join(workdir, 'secret.txt'), SECRET); mkdirSync(join(workdir, 'empty')); server = createServer({ root, port: 0 }); emptyServer = createServer({ root: join(workdir, 'empty'), port: 0 }); }); afterAll(async () => { await server.stop(true); await emptyServer.stop(true); rmSync(workdir, { recursive: true, force: true }); }); describe('direct navigation', () => { it.each(['/', '/benchmark', '/benchmark/', '/unknown/client/route', '/assets'])( 'serves the application shell for %s', async (path) => { const response = await fetch(new URL(path, server.url)); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toStartWith('text/html'); expect(response.headers.get('cache-control')).toBe('no-cache'); expect(await response.text()).toBe(INDEX); }, ); it('never gives the shell the long asset cache when requested by name', async () => { const response = await fetch(new URL('/index.html', server.url)); expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toBe('no-cache'); }); }); describe('static assets', () => { it.each([ [JS, 'javascript', 'console.log("app");'], ['styles-DEF456.css', 'css', 'body{margin:0}'], ['assets/data.json', 'json', '{"nested":true}'], ])('serves /%s with its content type and the long cache', async (file, type, body) => { const response = await fetch(new URL(`/${file}`, server.url)); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toContain(type); expect(response.headers.get('cache-control')).toBe(IMMUTABLE); expect(await response.text()).toBe(body); }); it('answers HEAD with the GET headers and no body', async () => { const get = await fetch(new URL(`/${JS}`, server.url)); const head = await fetch(new URL(`/${JS}`, server.url), { method: 'HEAD' }); expect(head.status).toBe(200); expect(head.headers.get('content-type')).toBe(get.headers.get('content-type')); expect(head.headers.get('content-length')).toBe(get.headers.get('content-length')); expect(head.headers.get('cache-control')).toBe(IMMUTABLE); expect(await head.text()).toBe(''); }); it('answers HEAD for a client route with no body', async () => { const head = await fetch(new URL('/benchmark', server.url), { method: 'HEAD' }); expect(head.status).toBe(200); expect(await head.text()).toBe(''); }); it.each(['/missing.js', '/assets/missing.css', '/nope.js/', '/favicon.ico'])( 'answers 404, not the shell, for missing asset %s', async (path) => { const response = await fetch(new URL(path, server.url)); expect(response.status).toBe(404); expect(response.headers.get('content-type')).toStartWith('text/plain'); expect(await response.text()).not.toContain('fixture app'); }, ); }); describe('methods', () => { it.each([`/${JS}`, '/benchmark'])('rejects POST to %s with 405', async (path) => { const response = await fetch(new URL(path, server.url), { method: 'POST', body: 'x' }); expect(response.status).toBe(405); expect(response.headers.get('allow')).toBe('GET, HEAD'); }); }); describe('path protection', () => { it.each([ '/../secret.txt', '/%2e%2e/secret.txt', '/..%2fsecret.txt', '/%2e%2e%2f%2e%2e%2fetc/passwd', '/..%5csecret.txt', '/assets/..%2f..%2fsecret.txt', '/%2e%2e%2fsecret.txt', '//secret.txt', '/%00', '/assets%00/data.json', ])('does not leak files outside the root through %s', async (target) => { const response = await rawGet(server.port!, target); expect([400, 404]).toContain(response.status); expect(response.text).not.toContain(SECRET); expect(response.text).not.toContain('root:'); }); it.each(['/..%2fsecret.txt', '/%2e%2e%2f%2e%2e%2fetc/passwd', '/%00'])( 'rejects %s as a bad request', async (target) => { expect((await rawGet(server.port!, target)).status).toBe(400); }, ); it('rejects malformed percent-encoding with 400 instead of failing', async () => { for (const target of ['/%ZZ', '/%E0%A4%A', '/assets/%']) { expect((await rawGet(server.port!, target)).status).toBe(400); } }); }); describe('errors', () => { it('answers 500 as plain text and logs it when the build has no index.html', async () => { const log = spyOn(console, 'error').mockImplementation(() => {}); const response = await fetch(new URL('/benchmark', emptyServer.url)); expect(response.status).toBe(500); expect(response.headers.get('content-type')).toStartWith('text/plain'); expect(await response.text()).toBe('Internal Server Error'); expect(log).toHaveBeenCalledTimes(1); log.mockRestore(); }); it('still answers a missing asset with 404 when index.html is absent', async () => { expect((await fetch(new URL('/missing.js', emptyServer.url))).status).toBe(404); }); });