Files
mdg-filius/packages/core/test/routerWebJava.test.ts
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

241 lines
11 KiB
TypeScript

// The pages of routers and home routers as Java FILIUS serves them.
// tools/java-oracle's RouterWebRun let a host's browser ask a router's or
// home router's web server for its index page, its routing table and its
// firewall log (routerweb-java-<story>.java.json). The engine plays the same
// stories: each card's frames must come in the same order, with the same
// content and rows, at about the same time; the server's lines and the
// browser's pages likewise. The pages are Java's German ones.
//
// The router's server switches itself on when the simulation starts (Java
// writes so into its window): lines from before the story's first call are
// not compared. Java's router server sometimes never listens (setAktiv wakes
// its thread only when it already waits), and takes no initial sequence
// number then; the engine's always listens: sequence numbers are compared
// below the millions. The settings page (konfig) changes the firewall at run
// time: a ping passes once "drop ICMP" is saved off; a rule moved above the
// one that accepts the browser shuts it out.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { LogLine, NetworkRuntimeEngine } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables } from './javaRun.js';
import { browserText, isPolled, POLL_MILLISECONDS, serverText } from './programLines.js';
import { snapshotOf, type Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const id = (suffix: number) => `00000000-0000-0000-0000-${String(suffix).padStart(12, '0')}`;
type Program = 'server' | 'browser';
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, string, string][];
}
interface Story {
readonly scenario: string;
readonly browser: string;
readonly router: string;
readonly acts: readonly [number, (engine: NetworkRuntimeEngine, browser: string) => void][];
}
const open =
(typed: string) =>
(engine: NetworkRuntimeEngine, browser: string): void => {
engine.webBrowser(browser)!.open(typed);
};
const post =
(typed: string, data: string) =>
(engine: NetworkRuntimeEngine, browser: string): void => {
engine.webBrowser(browser)!.open(typed, data);
};
const ping = (engine: NetworkRuntimeEngine, browser: string): void => {
engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 });
};
/** The form as Java's browser window posts it: every field, then the button pressed. */
const FORM = {
saveWithoutIcmp: 'firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop',
addTcpDrop: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen',
moveSecondUp: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&radioRule=2&moveup=rauf&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop',
};
const STORIES: Record<string, Story> = {
routerIndex: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1')]] },
routerRoutes: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/routes')]] },
routerKonfig: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/konfig')]] },
konfigIcmp: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, ping],
[3500, post('10.0.0.1/konfig', FORM.saveWithoutIcmp)],
[7000, ping],
],
},
konfigBlock: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, post('10.0.0.1/konfig', FORM.addTcpDrop)],
[4000, post('10.0.0.1/konfig', FORM.moveSecondUp)],
[8000, open('10.0.0.1')],
],
},
routerLog: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, ping],
[3500, open('10.0.0.1')],
[7000, open('10.0.0.1/log')],
],
},
gatewayIndex: {
scenario: 'nat-home',
browser: id(501),
router: id(502),
acts: [
[500, open('192.168.0.1')],
[4000, open('192.168.0.1/routes')],
],
},
gatewayFromWan: { scenario: 'nat-home', browser: id(504), router: id(502), acts: [[500, open('42.0.0.10')]] },
ripRoutes: { scenario: 'rip-chain', browser: id(401), router: id(402), acts: [[40_000, open('10.0.1.1/routes')]] },
};
/** Sequence numbers below the millions: which socket drew which initial number differs (see above). */
const withoutInitialNumbers = (text: string) =>
withClientPortText(text)
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
/** Java's wait for a SYN's answer (TCPSocket: three times 2.75 s, its timeout here). */
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
/**
* Where Java errs: its run corrected, as Java means it. routerLog: the
* German log page closes its title with </titel> (sw_firewallweblog_msg1);
* the engine writes </title>. konfigBlock: the browser's SYN, dropped by the
* firewall, goes once in Java, not three times (webJava.test.ts, noServer);
* the page "server not reachable" comes two attempts later in the engine.
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
konfigBlock: (java) => ({
...java,
events: java.events.map(([time, who, text]) => (who === 'browser' && text.startsWith('page 0') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])),
}),
routerLog: (java) => {
const fixed = (text: string | null) => text?.replace('</titel>', '</title>') ?? null;
const rows = (byCard: Record<string, (string | null)[][]> | undefined) =>
Object.fromEntries(Object.entries(byCard ?? {}).map(([card, cardRows]) => [card, cardRows.map((row) => row.map(fixed))]));
return { ...java, events: java.events.map(([time, who, text]) => [time, who, fixed(text)!]), rows: rows(java.rows), layeredRows: rows(java.layeredRows) };
},
};
/**
* ripRoutes: how long a learned route has left. Java's routers start one
* after the other and draw their beacons' pauses in another order (rip-chain
* in ripJava.test.ts: R2 sends three times at the start, not twice); a
* neighbour's beacon refreshes the route some seconds apart. Compared as a
* placeholder; the page's other cells exactly.
*/
const PAGE_PLACEHOLDERS: Record<string, (text: string) => string> = {
ripRoutes: (text) => text.replace(/<td>-?\d+<\/td>(<td>[\d.]+<\/td><td><a )/g, '<td>validity</td>$1'),
};
/** Where the frames are compared only up to a time (Java's). konfigBlock: the engine's second SYN, which Java does not send. */
const UNTIL: Record<string, (java: JavaRun) => number> = {
konfigBlock: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250,
};
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {
const lines: Lines = { server: [], browser: [] };
const firstCall = Math.min(...java.events.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time));
for (const [time, program, text] of java.events) {
if (program !== 'server' && program !== 'browser') continue;
if (time < firstCall || text === 'null' || text.startsWith('call ')) continue;
const page = /^page (\d+) (\S+)\n/.exec(text);
lines[program].push([time, withClientPortText(page !== null && page[1] !== '200' ? `page ${page[1]} ${page[2]}` : text)]);
}
return lines;
}
function engineLines(engine: NetworkRuntimeEngine, story: Story, firstCall: number): Lines {
const lines = <E>(log: readonly LogLine<E>[], text: (entry: E) => string | undefined): [number, string][] =>
log.flatMap((line) => {
const each = line.timeMilliseconds < firstCall ? undefined : text(line);
return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]];
});
return {
server: lines(engine.webServer(story.router)!.log.all, serverText),
browser: lines(engine.webBrowser(story.browser)!.log.all, browserText),
};
}
describe('the pages of routers and home routers as Java FILIUS serves them', () => {
it('corrects Java only where it errs, and puts in placeholders only where they change something', () => {
for (const [name, correct] of Object.entries(JAVA_BUGS)) {
const java = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun;
expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java));
}
for (const [name, placeholders] of Object.entries(PAGE_PLACEHOLDERS)) {
const text = readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8');
expect(placeholders(text), name).not.toBe(text);
}
});
for (const [name, story] of Object.entries(STORIES)) {
it(name, () => {
const measured = JSON.parse(readFileSync(join(scenarioDirectory, `routerweb-java-${name}.java.json`), 'utf8')) as JavaRun;
const java = JAVA_BUGS[name]?.(measured) ?? measured;
const placeholders = PAGE_PLACEHOLDERS[name] ?? ((text: string) => text);
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${story.scenario}.scenario.json`), 'utf8')) as Scenario;
const snapshot = { ...snapshotOf(scenario), webBrowserNodeIds: new Set([story.browser]) };
const { engine } = watchedEngine(scenario, snapshot);
for (const [time, act] of story.acts) {
engine.handle({ type: 'advance', toMilliseconds: time });
act(engine, story.browser);
}
engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 });
const firstCall = story.acts[0]![0];
// Before the story, only what the network does by itself (RIP); compared from the first call.
expectSameCaptures(engine, scenario, java.frames, {
text: withoutInitialNumbers,
cell: (text) => placeholders(withoutInitialNumbers(text)),
tables: java,
from: firstCall,
until: UNTIL[name]?.(measured),
starts: { java: 0, engine: 0 },
});
const withPlaceholders = (lines: Lines): Lines => ({
server: lines.server.map(([time, text]) => [time, placeholders(text)]),
browser: lines.browser.map(([time, text]) => [time, placeholders(text)]),
});
const expected = withPlaceholders(javaLines(java));
const actual = withPlaceholders(engineLines(engine, story, firstCall));
for (const program of ['server', 'browser'] as const) {
expect(actual[program].map(([, text]) => text), `${program}'s lines`).toEqual(expected[program].map(([, text]) => text));
for (const [index, [javaTime, text]] of expected[program].entries()) {
const late = javaTime - actual[program][index]![0];
const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + GAP_TOLERANCE_MILLISECONDS;
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeGreaterThanOrEqual(-GAP_TOLERANCE_MILLISECONDS);
expect(late, `${program}'s line ${JSON.stringify(text.slice(0, 60))}`).toBeLessThanOrEqual(most);
}
}
});
}
});