// The DNS server as Java's window changes it, against Java's // (dnsWindow.java.json, DnsWindowRun) on mail-lan's DNS server: Java's steps // played on the engine's server — the tables, /dns/hosts, which names the // first computer's resolver gets, the button and the box — and where Java // errs, what the server does instead (apps/dnsServer.ts names each): an // empty name refused, a cell changing its record only, a record added // keeping what another program wrote into the file. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { NetworkRuntimeEngine, validDnsRecord, VirtualFileSystem, withSnapshot, type DnsRecordType, type Steps } from '../src/index.js'; import { snapshotOf, type Scenario } from './scenario.js'; interface JavaStep { readonly step: string; readonly button: string; readonly recursive: boolean; readonly a: readonly (readonly string[])[]; readonly mx: readonly (readonly string[])[]; readonly ns: readonly (readonly string[])[]; readonly file: string; readonly dialogs: readonly string[]; readonly resolved?: Readonly>; } const here = dirname(fileURLToPath(import.meta.url)); const java = JSON.parse(readFileSync(join(here, 'dnsWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; const scenario = JSON.parse(readFileSync(join(here, 'scenarios/mail-lan.scenario.json'), 'utf8')) as Scenario; const CLIENT = '00000000-0000-0000-0000-000000000201'; const SERVER = '00000000-0000-0000-0000-000000000202'; function setUp() { const engine = new NetworkRuntimeEngine(scenario.seed); const files = new VirtualFileSystem(); engine.handle({ type: 'start', snapshot: withSnapshot(snapshotOf(scenario), { fileSystemsByNodeId: new Map([[SERVER, files]]) }), seed: scenario.seed, initialTimeMilliseconds: 0 }); const run = (milliseconds: number) => engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + milliseconds }); /** What the first computer's resolver gets for a name, as Java's oracle writes it. */ const resolve = (name: string): string => { let answer = '(no answer)'; engine.startThread( (function* (): Steps { const result = yield* engine.resolveDnsAddressSteps(CLIENT, name); answer = result.type === 'timedOut' ? 'TimeOutException' : String(result.address ?? null); })(), ); run(30_000); return answer; }; return { engine, server: engine.dnsServer(SERVER)!, files, run, resolve }; } describe("the DNS server's window, against Java's", () => { it("plays Java's steps: the same where Java is right, as named where it errs", () => { const { server, files, resolve } = setUp(); const rows = (type: DnsRecordType) => server.rows(type).map(({ record }) => [record.domainName, record.rdata]); const file = () => (files.contains('/dns/hosts') ? files.textFile('/dns/hosts') : undefined); // Where Java's file holds what Java got wrong, the file as it should be (named below at each step). const fixes: ((text: string) => string)[] = []; const ours = (text: string) => fixes.reduce((each, fix) => fix(each), text); const shown = (step: JavaStep) => { const expected = { button: step.button, recursive: step.recursive, a: ours(step.file) === step.file ? step.a : rowsOf(ours(step.file), 'A'), mx: rowsOf(ours(step.file), 'MX'), ns: step.ns, file: ours(step.file), }; const actual = { button: server.isActive ? 'Beenden' : 'Starten', recursive: server.recursiveResolutionEnabled, a: rows('A'), mx: rows('MX'), ns: rows('NS'), file: file() ?? ours(step.file) }; return { expected, actual }; }; const at = (name: string) => java.steps.find((step) => step.step === name)!; const check = (name: string, resolved: Readonly> | undefined = at(name).resolved) => { const { expected, actual } = shown(at(name)); expect(actual, name).toEqual(expected); if (resolved !== undefined) expect(Object.fromEntries(Object.keys(resolved).map((each) => [each, resolve(each)])), name).toEqual(resolved); }; check('open'); expect([server.addRecord('A', 'kein name!', '192.168.0.30'), server.addRecord('A', 'neu.schule.de', '192.168.0.300')]).toEqual([false, false]); expect(at('A: add an invalid name').dialogs).toHaveLength(1); check('A: add an invalid address'); expect(server.addRecord('A', 'neu.schule.de', '10.0.0.1')).toBe(true); check('A: add neu.schule.de'); expect(server.addRecord('A', 'neu.schule.de', '10.0.0.2')).toBe(true); check('A: add neu.schule.de again'); expect(server.addRecord('MX', 'schule.de', 'mail.schule.de')).toBe(true); check('MX: add schule.de'); // An empty name is refused (Java makes ". MX 3600 x."). expect(server.addRecord('MX', '', 'x')).toBe(false); fixes.push((text) => text.replace('. MX 3600 x.\n', '')); check('MX: add with no domain'); expect(server.addRecord('NS', 'schule.de', 'ns.schule.de')).toBe(true); check('NS: add schule.de'); expect(server.changeRecord('A', 0, 0, 'web.filius.de')).toBe(true); check("A: edit row 0's name to web.filius.de"); expect(server.changeRecord('A', 1, 1, '999.1.1.1')).toBe(false); check("A: edit row 1's address to 999.1.1.1"); expect(server.changeRecord('A', 2, 1, '192.168.0.99')).toBe(true); check("A: edit row 2's address"); // The MX record's server changes that record only (Java also writes it into the A record after it). expect(server.changeRecord('MX', 0, 1, 'post.filius.de')).toBe(true); fixes.push((text) => text.replace('mail.filius.de. A 3600 post.filius.de\n', 'mail.filius.de. A 3600 192.168.0.30\n')); check("MX: edit row 0's server"); // Row 5 is mail.andere.de; Java's file loses mail.filius.de too, which its mistake had made invalid. expect(server.removeRecord('A', 5)).toBe(true); fixes.push((text) => text.replace('filius.de. MX 3600 post.filius.de.\n', 'filius.de. MX 3600 post.filius.de.\nmail.filius.de. A 3600 192.168.0.30\n')); check('A: remove row 5'); server.setRecursiveResolutionEnabled(true); check('recursive on'); server.setActive(false); check('button once'); server.setActive(true); check('button twice'); // Another program writes /dns/hosts: answered at once; a record added keeps it (Java's add loses it). files.writeTextFile('/dns/hosts', `${files.textFile('/dns/hosts')}extern.schule.de. A 3600 10.0.0.9\n`); check('file changed by another program'); expect(server.addRecord('A', 'danach.schule.de', '10.0.0.3')).toBe(true); fixes.push((text) => text.replace('danach.schule.de.', 'extern.schule.de. A 3600 10.0.0.9\ndanach.schule.de.')); check('A: add after the file changed', { ...at('A: add after the file changed').resolved, 'extern.schule.de': '10.0.0.9' }); expect(server.removeRecord('A', 0)).toBe(true); check('A: remove row 0 after the file changed', { ...at('A: remove row 0 after the file changed').resolved, 'extern.schule.de': '10.0.0.9' }); }); }); /** The rows of a type the window shows for a file. */ function rowsOf(text: string, type: DnsRecordType): string[][] { return text .split('\n') .filter((line) => line.split(' ')[1] === type) .map((line) => [line.split(' ')[0]!, line.split(' ')[3]!]); } describe('the DNS server beyond the steps', () => { it('removes the row chosen of two of one name (Java the first of the name), and changes the row of its type', () => { const { server, files } = setUp(); server.addRecord('A', 'zwei.de', '10.0.0.1'); server.addRecord('A', 'zwei.de', '10.0.0.2'); const twins = () => server.rows('A').filter(({ record }) => record.domainName === 'zwei.de.'); expect(twins().map(({ record }) => record.rdata)).toEqual(['10.0.0.1', '10.0.0.2']); const second = server.rows('A').findIndex(({ record }) => record.rdata === '10.0.0.2'); expect(server.removeRecord('A', second)).toBe(true); expect(twins().map(({ record }) => record.rdata)).toEqual(['10.0.0.1']); expect([server.removeRecord('A', 99), server.changeRecord('NS', 0, 0, 'x.de')]).toEqual([false, false]); // A record's name and target in any form the file takes: the dots added, the TTL kept. files.writeTextFile('/dns/hosts', 'alt.de. A 60 10.0.0.5\n'); expect(server.changeRecord('A', 0, 0, ' neu.de ')).toBe(true); expect(files.textFile('/dns/hosts')).toBe('neu.de. A 60 10.0.0.5\n'); }); it("answers from its settings until it has a file, and its window's checks are Java's patterns without their errors", () => { const engine = new NetworkRuntimeEngine(scenario.seed); engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 }); const server = engine.dnsServer(SERVER)!; expect(server.rows('A').map(({ record }) => record.domainName)[0]).toBe('www.filius.de.'); expect(server.answering.map((record) => record.name)[0]).toBe('www.filius.de'); expect(engine.state.topologySnapshot.fileSystemsByNodeId.has(SERVER)).toBe(false); server.addRecord('NS', 'schule.de', 'ns.schule.de'); expect(engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!.textFile('/dns/hosts').split('\n').at(-2)).toBe('schule.de. NS 3600 ns.schule.de.'); expect( [ ['A', 'a.de', '1.2.3.4'], ['A', '', '1.2.3.4'], ['A', 'a.de', ''], ['A', 'a.de', '1.2.3'], ['A', '9a.de', '1.2.3.4'], ['A', 'a.123', '1.2.3.4'], ['MX', 'a.de', 'mail.a.de'], ['MX', 'a.de', ''], ['NS', 'a.de', '1.2.3.4'], // Java's pattern and IP.ipCheck both: each alone takes one of these. ['A', 'a.de', '1.2.3.4.'], ['A', 'a.de', '.1.2.3'], ['A', 'a.de', '01.2.3.4'], ].map(([type, name, data]) => validDnsRecord(type as DnsRecordType, name!, data!)), ).toEqual([true, false, false, false, true, false, true, false, false, false, false, true]); // A folder named /dns/hosts is no file: the settings' records. const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(SERVER)!; files.deleteItem('/dns/hosts'); files.createDirectory('/dns/hosts'); expect(server.records.map((record) => record.domainName)[0]).toBe('www.filius.de.'); }); it('runs a server switched off only once it is switched on, and an installed one switched off', () => { const engine = new NetworkRuntimeEngine(scenario.seed); const snapshot = snapshotOf(scenario); const configuration = snapshot.dnsServerConfigurationsByNodeId.get(SERVER)!; engine.handle({ type: 'start', snapshot: withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: new Map([[SERVER, { ...configuration, isActive: false }]]) }), seed: scenario.seed, initialTimeMilliseconds: 0, }); const server = engine.dnsServer(SERVER)!; expect([server.isActive, engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([false, false]); server.setActive(true); server.setActive(true); expect([server.isActive, engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([true, true]); engine.removeProgram(SERVER, 'dnsServer'); expect([engine.dnsServer(SERVER), engine.state.dnsServerSocketIdsByNodeId.has(SERVER)]).toEqual([undefined, false]); engine.installProgram(SERVER, 'dnsServer', withSnapshot(snapshot, { dnsServerConfigurationsByNodeId: new Map([[SERVER, { ...configuration, isActive: false }]]) })); expect(engine.dnsServer(SERVER)?.isActive).toBe(false); }); });