// 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: '

\n

' }); expect(text).toBe('HTTP/1.1 200 OK\nContent-type: text/html\n\n

\n

'); expect(parseHttpResponse(text)).toEqual({ protocolVersion: 'HTTP/1.1', statusCode: 200, contentType: 'text/html', body: '

\n

' }); 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(' xno 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 { 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): 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('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': '', '/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: '' }); 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': '' }))); 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']); }); });