Files
vac-optimizer/src/server.spec.ts
T
nessarandClaude Opus 5.5 f74d744f9b feat(server): serve the client-only build with Bun
Angular's server build needs Node loader hooks that Bun does not apply,
and every route is client-rendered, so Angular now builds a client-only
bundle and a small Bun.serve server delivers it: hashed assets with the
one-year cache, index.html revalidated, extension-less routes falling
back to index.html, missing assets 404, traversal and malformed paths
rejected, and other methods 405. Express, @angular/ssr and the unused
hydration provider are removed. Both Docker stages use the pinned
oven/bun:1.4.3-alpine image without Node, and the health checks use
Bun's fetch.

Refs #292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:50:05 +02:00

175 lines
6.2 KiB
TypeScript

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 = '<!doctype html><title>fixture app</title>';
const SECRET = 'top-secret-outside-the-root';
const JS = 'main-ABC123.js';
const IMMUTABLE = 'public, max-age=31536000';
let workdir: string;
let server: ReturnType<typeof createServer>;
let emptyServer: ReturnType<typeof createServer>;
// 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);
});
});