Files
mdg-filius/packages/core/test/web.test.ts
T
julian.kiedaischandClaude Opus 5.5 8438b42018 feat(core): the firewall settings page of routers after Java, measured against Java
The page konfig changes the firewall at run time (into the snapshot, back
into the project through projectWithSimulationChanges). KonfigCases feeds
Java's plug-in directly (25 cases, de and en); RouterWebRun plays two
stories over the network. Ten Java bugs named, not adopted. The web server
answers a method not written GET or POST with 501, as Java does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:13:31 +00:00

238 lines
12 KiB
TypeScript

// HTTP as FILIUS writes it (http.ts), and the web server and browser
// (apps/web.ts) where webJava.test.ts's stories do not go.
import { describe, expect, it } from 'vitest';
import {
decodeUriPath,
deviceConfiguration,
encodeUriPath,
formatHttpRequest,
formatHttpResponse,
imageSources,
javaBase64,
javaBase64Decode,
parseHttpRequest,
parseHttpResponse,
VirtualFileSystem,
webBrowserAddress,
type NetworkRuntimeEngine,
type TopologySnapshotInput,
} from '../src/index.js';
import { link, pc, startedEngine, uuid } from './fixtures.js';
describe('HTTP as FILIUS writes it', () => {
it('writes a request with the path encoded and without its query, and a POST with its data', () => {
expect(formatHttpRequest({ method: 'GET', path: '/mein bild.html?x=1', host: 'www.filius.de' })).toBe('GET /mein%20bild.html HTTP/1.1\nHost: www.filius.de\n');
expect(formatHttpRequest({ method: 'POST', path: '', host: undefined, data: 'a=b' })).toBe('POST / HTTP/1.1\n\na=b');
});
it('reads a request: method in any case, Host header, POST data after the empty line', () => {
expect(parseHttpRequest('post /form%C3%BC HTTP/1.0\nhost: a.de\n\nx=1\ny=2')).toEqual({
type: 'request',
request: { method: 'post', path: '/formü', protocolVersion: 'HTTP/1.0', host: 'a.de', data: '\nx=1\ny=2' },
});
});
it('answers what it cannot read with 400 or 501, and takes a request without a version as HTTP/1.1 (Java: the thread dies)', () => {
expect(parseHttpRequest('')).toEqual({ type: 'invalid', statusCode: 400 });
expect(parseHttpRequest('GET')).toEqual({ type: 'invalid', statusCode: 400 });
expect(parseHttpRequest('HALLO Welt')).toEqual({ type: 'invalid', statusCode: 501 });
expect(parseHttpRequest('GET /%zz HTTP/1.1')).toEqual({ type: 'invalid', statusCode: 400 });
expect(parseHttpRequest('GET /')).toMatchObject({ type: 'request', request: { path: '/', protocolVersion: 'HTTP/1.1' } });
});
it('writes and reads a response', () => {
const text = formatHttpResponse({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '<p>\n</p>' });
expect(text).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\n<p>\n</p>');
expect(parseHttpResponse(text)).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '<p>\n</p>' });
expect(parseHttpResponse('HTTP/1.1 404 Not Found\n')).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 404, contentType: undefined, body: undefined });
expect(parseHttpResponse('HTTP/1.1 200 OK\ncontent-TYPE: image/png\n\nx')).toMatchObject({ contentType: 'image/png', body: 'x' });
expect(parseHttpResponse('Hallo Welt')).toBeUndefined();
});
it('encodes a path as Spring UriUtils.encodePath and decodes it back', () => {
expect(encodeUriPath("/a b/ü?#%[]-._~!$&'()*+,;=:@")).toBe("/a%20b/%C3%BC%3F%23%25%5B%5D-._~!$&'()*+,;=:@");
expect(decodeUriPath('/a%20b/%C3%BC+')).toBe('/a b/ü+');
expect(decodeUriPath('/50%')).toBeUndefined();
});
it('reads and writes Base64 as FILIUS does, line breaks and all', () => {
const bytes = new Uint8Array(100).map((_, index) => index * 7);
expect(javaBase64Decode(javaBase64(bytes))).toEqual(bytes);
expect(javaBase64Decode('QUJD\nRA==')).toEqual(new TextEncoder().encode('ABCD'));
expect(javaBase64Decode('QUJ')).toBeUndefined();
expect(javaBase64Decode('QU=D')).toBeUndefined();
});
});
describe("the browser's address line (erzeugeURL)", () => {
it('takes a host with or without http://, the path without a final slash', () => {
expect(webBrowserAddress('www.filius.de')).toEqual({ host: 'www.filius.de', path: '/' });
expect(webBrowserAddress('HTTP://192.168.0.30/a/b/')).toEqual({ host: '192.168.0.30', path: '/a/b' });
expect(webBrowserAddress('http:///text.txt')).toEqual({ host: '', path: '/text.txt' });
expect(webBrowserAddress('/text.txt')).toEqual({ host: '', path: '/text.txt' });
});
it('is no address for what Java does not take as a host', () => {
expect(webBrowserAddress('www.filius.de:8080/')).toBeUndefined();
expect(webBrowserAddress('1fehler.de')).toBeUndefined();
expect(webBrowserAddress('http:')).toBeUndefined();
});
});
describe('the images of a page', () => {
it('finds each img tag, in any case, its src in any quotes', () => {
expect(imageSources('<img src="a.png"><IMG SRC=\'b.png\'> <img alt="x" src=c/d.png><img\nsrc = "e.png" /><imgx src="no.png"><img alt="no src">')).toEqual([
'a.png',
'b.png',
'c/d.png',
'e.png',
]);
});
});
const SERVER = uuid(0x3001);
const CLIENT = uuid(0x3002);
function network(files: VirtualFileSystem, changes: Partial<TopologySnapshotInput> = {}): TopologySnapshotInput {
return {
nodes: [pc(SERVER, uuid(0x3011)), pc(CLIENT, uuid(0x3012))],
links: [link(SERVER, uuid(0x3011), CLIENT, uuid(0x3012))],
deviceConfigurations: new Map([
[SERVER, deviceConfiguration('10.0.0.1', '255.255.255.0', '', '')],
[CLIENT, deviceConfiguration('10.0.0.2', '255.255.255.0', '', '')],
]),
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }]]),
webBrowserNodeIds: new Set([CLIENT]),
fileSystemsByNodeId: new Map([[SERVER, files]]),
...changes,
};
}
function files(entries: Record<string, string>): VirtualFileSystem {
const fileSystem = new VirtualFileSystem();
fileSystem.createDirectory('/webserver', true);
for (const [path, text] of Object.entries(entries)) {
const parent = path.slice(0, path.lastIndexOf('/'));
if (parent !== '' && !fileSystem.contains(parent)) fileSystem.createDirectory(parent, true);
fileSystem.writeTextFile(path, text);
}
return fileSystem;
}
/** The server's answers to the requests, one after the other over one connection. */
function ask(engine: NetworkRuntimeEngine, ...requests: string[]): string[] {
const socket = engine.openTcpClientSocket(CLIENT, '10.0.0.1', 80)!;
expect(engine.connectTcpWithResult(socket)).toBe('connected');
return requests.map((request) => {
expect(engine.sendTcp(socket, new TextEncoder().encode(request))).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 2_000 });
return new TextDecoder().decode(engine.receiveTcp(socket) ?? new Uint8Array());
});
}
describe('the web server', () => {
it('answers 400 and 501 and stays connected, where Java closes the connection', () => {
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
expect(ask(engine, 'HALLO', 'GET', 'GET /')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 400 Bad Request\n', 'HTTP/1.1 200 OK\nContent-type: text/html\n\nStart']);
});
it('answers only GET and POST as Java writes them: a method in small letters is read, then answered 501 (WebServerMitarbeiter)', () => {
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
expect(ask(engine, 'get /', 'Post /\n\nx=1')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 501 Not Implemented\n']);
});
it('serves nothing outside its document root (Java serves any file of the host), and no folder', () => {
const engine = startedEngine(network(files({ '/geheim.txt': 'geheim', '/webserver/ordner/a.txt': 'a' })));
expect(ask(engine, 'GET /../geheim.txt HTTP/1.1', 'GET /ordner/../../geheim.txt HTTP/1.1', 'GET /ordner HTTP/1.1', 'GET /ordner/./a.txt?x HTTP/1.1')).toEqual([
'HTTP/1.1 404 Not Found\n',
'HTTP/1.1 404 Not Found\n',
'HTTP/1.1 404 Not Found\n',
'HTTP/1.1 200 OK\nContent-type: text/plain\n\na',
]);
});
it('names the content by the file name: htm, bmp, gif, jpg; jpeg is plain text, as in Java', () => {
const fileSystem = files({ '/webserver/a.HTM': 'a' });
const image = new Uint8Array([1, 2, 3, 4]);
for (const name of ['b.bmp', 'c.gif', 'd.jpg', 'e.jpeg']) fileSystem.writeImageFile(`/webserver/${name}`, image, 'image/png');
fileSystem.writeBinaryFile('/webserver/f.bin', image);
const engine = startedEngine(network(fileSystem));
const types = ask(engine, ...['a.HTM', 'b.bmp', 'c.gif', 'd.jpg', 'e.jpeg', 'f.bin'].map((name) => `GET /${name} HTTP/1.1`)).map(
(answer) => answer.split('\n')[1],
);
expect(types).toEqual(['Content-type: text/html', 'Content-type: image/bmp', 'Content-type: image/gif', 'Content-type: image/jpg', 'Content-type: text/plain', 'Content-type: text/plain']);
expect(ask(engine, 'GET /f.bin HTTP/1.1')[0]!.endsWith(`\n\n${javaBase64(image)}`)).toBe(true);
});
it('serves by host name only from the first five rows of www.conf/vhosts, ignoring case, and from its configuration', () => {
const vhosts = Array.from({ length: 6 }, (_, index) => `h${index}.de\nv${index}`).join('\n');
const entries = Object.fromEntries(Array.from({ length: 6 }, (_, index) => [`/webserver/v${index}/index.html`, `V${index}`]));
const engine = startedEngine(network(files({ ...entries, '/webserver/index.html': 'Start', '/www.conf/vhosts': vhosts })));
const pages = ask(engine, 'GET / HTTP/1.1\nHost: H4.DE', 'GET / HTTP/1.1\nHost: h5.de', 'GET / HTTP/1.1').map((answer) => answer.split('\n\n')[1]);
expect(pages).toEqual(['V4', 'Start', 'Start']);
// Without the file, the table is empty: the configuration's switch alone serves the root.
const plain = startedEngine(
network(files({ '/webserver/index.html': 'Start' }), {
webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: true, isActive: true }]]),
}),
);
expect(ask(plain, 'GET / HTTP/1.1\nHost: h0.de')[0]).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\nStart');
});
it('serves nothing without files', () => {
const engine = startedEngine({ ...network(new VirtualFileSystem()), fileSystemsByNodeId: new Map() });
expect(ask(engine, 'GET / HTTP/1.1')).toEqual(['HTTP/1.1 404 Not Found\n']);
});
});
describe('the web browser', () => {
it('keeps the page and its images, and goes to the last host for a link without one', () => {
const fileSystem = files({ '/webserver/index.html': '<img src="a.png"><img src="b.png">', '/webserver/seite.html': 'Seite' });
fileSystem.writeImageFile('/webserver/a.png', new Uint8Array([9, 8, 7]), 'image/png');
const engine = startedEngine(network(fileSystem));
const browser = engine.webBrowser(CLIENT)!;
expect(browser.open('10.0.0.1')).toBe(true);
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(browser.page).toMatchObject({ statusCode: 200, body: '<img src="a.png"><img src="b.png">' });
expect([...browser.page!.images]).toEqual([['a.png', new Uint8Array([9, 8, 7])]]);
expect(browser.log.all.filter((line) => line.kind === 'image').map((line) => line.kind === 'image' && line.statusCode)).toEqual([200, 404]);
browser.load('', '/seite.html');
engine.handle({ type: 'advance', toMilliseconds: 20_000 });
expect(browser.host).toBe('10.0.0.1');
expect(browser.page).toMatchObject({ statusCode: 200, body: 'Seite' });
expect(browser.page!.images.size).toBe(0);
});
it('fetches images only for an HTML page', () => {
const engine = startedEngine(network(files({ '/webserver/text.txt': '<img src="a.png">' })));
const browser = engine.webBrowser(CLIENT)!;
browser.open('10.0.0.1/text.txt');
engine.handle({ type: 'advance', toMilliseconds: 10_000 });
expect(browser.log.all.map((line) => line.kind)).toEqual(['page']);
});
it('does nothing for what is no address', () => {
const engine = startedEngine(network(files({})));
expect(engine.webBrowser(CLIENT)!.open('kein host:80')).toBe(false);
engine.handle({ type: 'advance', toMilliseconds: 5_000 });
expect(engine.webBrowser(CLIENT)!.log.all).toEqual([]);
expect(engine.capture.frames(uuid(0x3012))).toEqual([]);
});
it('shows "server not reachable" when the server closes without an answer (Java shows nothing)', () => {
const engine = startedEngine(network(files({}), { webServersByNodeId: new Map() }));
const listener = engine.openTcpServerSocket(SERVER, 80)!;
const browser = engine.webBrowser(CLIENT)!;
browser.open('10.0.0.1');
for (let time = 100; time < 20_000; time += 100) {
engine.handle({ type: 'advance', toMilliseconds: time });
const accepted = engine.state.tcpAcceptedSocketIdsByListenerId.get(listener)?.[0];
if (accepted !== undefined && engine.state.socketsById.get(accepted)?.tcpState === 'ESTABLISHED') engine.closeTcpSocket(accepted);
}
expect(browser.log.all.map((line) => line.kind)).toEqual(['unreachable']);
});
});