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>
175 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|