// The device sheet: what a field takes, measured against FILIUS' own settings // panels (tools/java-oracle's SheetRun typed the texts of sheet.cases.txt // into them, sheet.java.json), and what its changes do to the document — the // wireless connections moved as Java moves them, each change one step of // the person's undo, and everything kept through a saved file. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { javaIpCheck } from '@mdg-filius/core'; import { openProjectArchive, saveProjectArchive } from '@mdg-filius/fls'; import { toY, addDevice, checkField, connect, createUndoManager, deviceSheet, docSnapshot, readProject, renameDevice, ROOTS, setDhcpClient, setHostAddress, setHostLabels, setModemCode, setModemConnection, setHostWireless, setIpForwarding, setModemPort, setSwitchCloud, setSwitchRetention, setSwitchSsid, addRoute, addRouterInterface, connectionsClick, removeRouterInterface, swapRouterCables, addFirewallRule, moveFirewallRule, removeFirewallRule, setFirewallDefaultPolicy, setFirewallFlag, setFirewallRuleCell, setFirewallRuleChoice, type FirewallRuleColumn, addDhcpStaticAssignment, addPortForwarding, removeDhcpStaticAssignment, removePortForwarding, removeRoute, setDhcpServerAddress, setDhcpServerFlag, setDhcpStaticCell, setPortForwardingCell, setInterfaceAddress, setRip, setRouteCell, setRouterGateway, topologyView, type FieldRule, type HomeRouterSheet, type HostSheet, type NewDevice, type RouterSheet, } from '../src/index.js'; interface JavaCase { readonly device: 'host' | 'switch' | 'modem' | 'router' | 'homeRouter' | 'dhcpHost' | 'dhcpHome'; readonly field: string; readonly input: string; readonly start: string; readonly marked: boolean; /** null: Java's card lost its address. */ readonly stored: string | null; readonly shown: string; /** What Java's panel threw, if it did. */ readonly error?: string; } interface JavaRow { readonly cells: readonly (string | null)[]; readonly manual: boolean; } const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'sheet.java.json'), 'utf8')) as { readonly cases: readonly JavaCase[]; readonly flows: { readonly useMacAsNameThenOff: string; readonly useIpAsNameThenOff: string; readonly dhcpClientStopsServer: boolean; readonly dhcpClient: boolean; readonly homeRouterDhcpClientStopsServer: boolean; readonly homeRouterDhcpClient: boolean; }; readonly tables: Readonly>; readonly edits: Readonly>; readonly dhcp: { readonly dhcpHostShown: Readonly>; readonly dhcpHomeShown: Readonly>; readonly hostWithoutGatewayShown: readonly [string, string]; readonly staticAssignments: readonly (readonly [string, string])[]; readonly addable: readonly boolean[]; }; readonly forwarding: { readonly rows: readonly (readonly [string, string, string, string])[]; readonly valid: readonly boolean[] }; readonly firewall: { readonly newRule: JavaFirewallRule; readonly newFirewall: readonly [boolean, number, boolean, boolean]; readonly cells: readonly { readonly column: number; readonly input: string; readonly rule: JavaFirewallRule; readonly valid: boolean }[]; readonly moves: readonly (readonly [string, number, boolean | null, readonly string[]])[]; }; readonly connections: { readonly steps: readonly JavaConnectionsStep[]; readonly hostPortFree: boolean; readonly switchPortsFree: boolean; }; }; /** A click in Java's connections dialog ("c" a card's button, "+", "-") and what the dialog and the router show then. */ interface JavaConnectionsStep { readonly click: string; readonly local: readonly string[]; readonly remote: readonly string[]; readonly buttons: readonly ('off' | 'marked' | 'plugged' | 'free')[]; readonly add: boolean; readonly remove: boolean; /** Address, mask and the device its cable joins ("" for none), per card. */ readonly cards: readonly (readonly [string, string, string])[]; readonly cables: number; readonly panelError?: string | null; readonly gateway?: string | null; } interface JavaFirewallRule { readonly srcIP: string; readonly srcMask: string; readonly destIP: string; readonly destMask: string; readonly port: number; readonly protocol: number; readonly action: number; } const RULES: Readonly> = { 'host name': 'name', 'host ip': 'ipAddress', 'host mask': 'subnetMask', 'host gateway': 'ipAddressOrEmpty', 'host dns': 'ipAddressOrEmpty', 'switch name': 'name', 'switch ssid': 'ssid', 'switch retention': 'seconds', 'modem name': 'name', 'modem port': 'port', 'router name': 'name', 'router ip': 'ipAddress', 'router mask': 'subnetMask', 'router gateway': 'ipAddressOrEmpty', 'homeRouter name': 'name', 'homeRouter gateway': 'ipAddressOrEmpty', 'homeRouter wanIp': 'ipAddress', 'homeRouter wanMask': 'subnetMask', 'homeRouter lanIp': 'ipAddress', 'homeRouter lanMask': 'subnetMask', // The DHCP dialog checks every address with musterIpAdresse: none may be empty. 'dhcpHost lower': 'ipAddress', 'dhcpHost upper': 'ipAddress', 'dhcpHost gateway': 'ipAddress', 'dhcpHost dns': 'ipAddress', 'dhcpHome lower': 'ipAddress', 'dhcpHome dns': 'ipAddress', }; /** Fields Java's dialog shows but never takes: a home router hands out its LAN address as the gateway. */ const NOT_OFFERED = new Set(['dhcpHome gateway']); /** Fields whose text is judged without the spaces around it. */ const TRIMMED = new Set( Object.entries(RULES) .filter(([key, rule]) => rule !== 'name' && rule !== 'ssid') .map(([key]) => key), ); /** Whether Java's device took the text: its value changed (to an address: null is a card that lost its own), or was the text already. */ const tookIt = (each: JavaCase) => each.stored !== null && (each.stored !== each.start || each.input === each.start) && (!keptAsTyped(each) || javaIpCheck(each.stored) !== null); /** A DHCP server keeps its addresses as typed ("010.0.0.1"); the model keeps IP.ipCheck's form. */ const keptAsTyped = (each: JavaCase) => each.device.startsWith('dhcp'); /** What Java's model holds in the units of the sheet's value: seconds for the retention time, an address in IP.ipCheck's form. */ const javaValue = (each: JavaCase) => (each.field === 'retention' ? String(Number(each.stored) / 1000) : keptAsTyped(each) ? javaIpCheck(each.stored!) : each.stored); /** The case Java judged for a text: the trimmed text's own case where spaces are left out. */ function judged(each: JavaCase): JavaCase { const key = `${each.device} ${each.field}`; if (!TRIMMED.has(key) || each.input.trim() === each.input) return each; const twin = java.cases.find((other) => other.device === each.device && other.field === each.field && other.input === each.input.trim()); if (twin === undefined) throw new Error(`no case for ${JSON.stringify(each.input.trim())} (${key})`); return twin; } describe("a field's rule, against FILIUS' settings panels", () => { it('has a case for every field the sheet checks', () => { expect(new Set(java.cases.map((each) => `${each.device} ${each.field}`))).toEqual(new Set([...Object.keys(RULES), ...NOT_OFFERED])); expect(java.cases.length).toBeGreaterThan(70); }); it('takes a text exactly when Java neither marks nor refuses it, as the value Java keeps', () => { for (const each of java.cases) { const key = `${each.device} ${each.field}`; if (NOT_OFFERED.has(key)) continue; const verdict = judged(each); // Taken: the value changed, or the text was the value already. const took = tookIt(verdict); // An empty gateway Java's setter takes as "none", though a router's panel marks it (its key listener checks musterIpAdresse). const emptyTaken = RULES[key] === 'ipAddressOrEmpty' && verdict.input.trim() === '' && took; // A name is taken as typed. A modem's port Java never marks: it takes any int; here Java's own rule for ports. const expected = RULES[key] === 'name' ? true : key === 'modem port' ? took && /^([1-9][0-9]{0,4})$/.test(verdict.input) && Number(verdict.input) <= 65_535 : emptyTaken || (!verdict.marked && took); const checked = checkField(RULES[key]!, each.input); expect('value' in checked, `${key} ${JSON.stringify(each.input)}`).toBe(expected); if ('value' in checked) expect(checked.value, `${key} ${JSON.stringify(each.input)}`).toBe(RULES[key] === 'name' ? each.input : javaValue(verdict)); } }); it("names where Java's two judgements disagree, which here are one", () => { const disagreeing = java.cases.filter( (each) => !NOT_OFFERED.has(`${each.device} ${each.field}`) && RULES[`${each.device} ${each.field}`] !== 'name' && each.field !== 'port' && each.marked === tookIt(each), ); const kept = (each: JavaCase) => each.stored !== null && each.stored !== each.start && !tookIt(each); // Marked red, yet taken; or black, yet refused (the gateway's spaces Java trims to empty) — or lost: a card without its address. const verdictOf = (each: JavaCase) => each.marked ? 'marked, taken' : each.stored === null ? `lost${each.error === undefined ? '' : `, ${each.error}`}` : kept(each) ? 'kept, no address' : 'refused'; expect(disagreeing.map((each) => `${each.device} ${each.field} ${JSON.stringify(each.input)} ${verdictOf(each)}`)).toEqual([ 'host ip "10..0.0.1" marked, taken', 'host ip ".0.0.1" refused', 'host ip "+10.0.0.1" marked, taken', 'host ip "10.0.0.1." marked, taken', 'host ip "١٠.0.0.1" marked, taken', 'host ip "-0.0.0.1" marked, taken', 'host mask "255..255.0.0" marked, taken', 'host gateway " " marked, taken', 'host gateway "192.168..1.1" marked, taken', 'host dns " " marked, taken', 'switch retention "" refused', 'switch retention "-5" marked, taken', 'switch retention "99999999999999999999" refused', 'switch retention "٣" marked, taken', 'router ip ".0.0.1" lost, NullPointerException', 'router gateway "" marked, taken', 'router gateway " " marked, taken', 'router gateway "192.168..0.1" marked, taken', 'homeRouter gateway "" marked, taken', 'homeRouter wanIp ".1.2.3" lost', 'dhcpHost lower ".0.0.1" kept, no address', ]); const ports = java.cases.filter((each) => each.field === 'port' && each.stored !== each.start); expect(ports.map((each) => each.input)).toEqual(['0', '65535', '65536', '-1', '080', '80']); }); it("shows what the device holds once a field is left, as Java's field does", () => { for (const each of java.cases) if (each.field !== 'retention') expect(each.shown, each.input).toBe(each.stored ?? ''); }); }); let ids = 0; const newId = () => `20000000-0000-0000-0000-${(++ids).toString(16).padStart(12, '0')}`; function add(doc: Y.Doc, kind: NewDevice['kind'], name: string = kind, interfaces?: number): string { const id = newId(); addDevice(doc, { kind, id, name, x: 100, y: 100, newId, ...(interfaces === undefined ? {} : { interfaces }) }, 'me'); return id; } const host = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HostSheet; describe("a PC's sheet", () => { it("shows a new PC as Java's panel does", () => { const doc = new Y.Doc(); const pc = add(doc, 'pc', 'Rechner'); const sheet = host(doc, pc); expect({ ...sheet, macAddress: undefined }).toEqual({ kind: 'pc', name: 'Rechner', labels: { ipAddress: false, macAddress: false }, macAddress: undefined, ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '', dhcpClient: false, ipForwarding: false, wireless: false, ssid: '', wirelessPossible: true, networks: [], dhcpServer: { isActive: false, lowerBoundIPAddress: '0.0.0.0', upperBoundIPAddress: '0.0.0.0', subnetMask: '255.255.255.0', gateway: '0.0.0.0', gatewayEditable: false, dnsServer: '0.0.0.0', dnsServerEditable: false, useOwnSettings: false, ownSettingsOffered: true, staticAssignments: [], }, }); expect(sheet.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); expect(deviceSheet(doc, 'nobody')).toBeUndefined(); }); it('takes an address as Java keeps it, keeps the old one for a marked text, and says which', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); expect(setHostAddress(doc, pc, 'ipAddress', ' 010.000.000.001 ', 'me')).toEqual({ value: '10.0.0.1' }); expect(setHostAddress(doc, pc, 'ipAddress', '10.0.0.256', 'me')).toEqual({ invalid: true }); expect(setHostAddress(doc, pc, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' }); expect(setHostAddress(doc, pc, 'defaultGateway', '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' }); expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8', 'me')).toEqual({ invalid: true }); expect(setHostAddress(doc, pc, 'dnsServer', '8.8.8.8', 'me')).toEqual({ value: '8.8.8.8' }); expect(readProject(doc).deviceConfigurations.get(pc)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.0.255.0', defaultGateway: '10.0.0.254', dnsServer: '8.8.8.8' }); expect(setHostAddress(doc, pc, 'defaultGateway', '', 'me')).toEqual({ value: '' }); expect(host(doc, pc).defaultGateway).toBe(''); expect(setHostAddress(doc, 'nobody', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' }); }); it('is renamed, and shows its address, its MAC address or both as its name', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); renameDevice(doc, pc, 'PC-A', 'me'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); expect(topologyView(doc).nodes[0]!.label).toBe('192.168.0.10'); setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me'); expect(topologyView(doc).nodes[0]!.label).toBe(host(doc, pc).macAddress); setHostLabels(doc, pc, { ipAddress: true, macAddress: true }, 'me'); expect(host(doc, pc).labels).toEqual({ ipAddress: true, macAddress: true }); expect(topologyView(doc).nodes[0]!.label).toBe(`192.168.0.10 (${host(doc, pc).macAddress})`); setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me'); expect(topologyView(doc).nodes[0]!.label).toBe('PC-A'); }); it("keeps its name while it shows its MAC address and another field is taken (Java's panel overwrites it)", () => { // Java: the name became the MAC address; with the address shown instead, it stayed. expect(java.flows.useMacAsNameThenOff).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); expect(java.flows.useIpAsNameThenOff).toBe('PC-A'); const doc = new Y.Doc(); const pc = add(doc, 'pc', 'PC-A'); setHostLabels(doc, pc, { ipAddress: false, macAddress: true }, 'me'); setHostAddress(doc, pc, 'defaultGateway', '192.168.0.1', 'me'); setHostLabels(doc, pc, { ipAddress: false, macAddress: false }, 'me'); expect(host(doc, pc).name).toBe('PC-A'); }); it("keeps IP forwarding only where it differs from the kind's", () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); const router = add(doc, 'router'); setIpForwarding(doc, pc, true, 'me'); setIpForwarding(doc, router, false, 'me'); expect(host(doc, pc).ipForwarding).toBe(true); expect(readProject(doc).ipForwardingByNodeId).toEqual( new Map([ [pc, true], [router, false], ]), ); setIpForwarding(doc, pc, false, 'me'); setIpForwarding(doc, router, true, 'me'); expect(readProject(doc).ipForwardingByNodeId).toEqual(new Map()); }); it("uses DHCP, which stops its own DHCP server, as Java's panel does", () => { expect(java.flows).toMatchObject({ dhcpClient: true, dhcpClientStopsServer: true }); const doc = new Y.Doc(); const pc = add(doc, 'pc'); const settings = doc.getMap>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map; const server = new Y.Map(); server.set('isActive', true); settings.set('dhcpServer', server); setDhcpClient(doc, pc, true, 'me'); expect(host(doc, pc).dhcpClient).toBe(true); expect(readProject(doc).dhcpClientConfigurations.get(pc)).toEqual({ isEnabled: true }); expect(server.get('isActive')).toBe(false); setDhcpClient(doc, pc, false, 'me'); expect(host(doc, pc).dhcpClient).toBe(false); expect(readProject(doc).dhcpClientConfigurations.has(pc)).toBe(false); }); }); describe('a wireless card', () => { /** Two switches with their SSIDs and a notebook. */ function wlan() { const doc = new Y.Doc(); const first = add(doc, 'networkSwitch'); const second = add(doc, 'networkSwitch'); const notebook = add(doc, 'notebook'); setSwitchSsid(doc, first, 'Schule', 'me'); setSwitchSsid(doc, second, 'Gast', 'me'); const ends = () => readProject(doc).graph.links.map((link) => [link.sourceNodeId, link.targetNodeId]); return { doc, first, second, notebook, ends }; } it('offers the switches’ networks, each once', () => { const { doc, notebook } = wlan(); const third = add(doc, 'networkSwitch'); setSwitchSsid(doc, third, 'Schule', 'me'); expect(host(doc, notebook).networks).toEqual(['Schule', 'Gast']); }); it('joins the first switch with its SSID, moves with the SSID and leaves on the cable', () => { const { doc, first, second, notebook, ends } = wlan(); expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(ends()).toEqual([[notebook, first]]); expect(topologyView(doc).links[0]!.wireless).toBe(true); expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Schule', wirelessPossible: true }); expect(setHostWireless(doc, notebook, true, 'Gast', 'link-2', 'me')).toEqual({ link: 'link-2' }); expect(ends()).toEqual([[notebook, second]]); // The same again changes nothing. expect(setHostWireless(doc, notebook, true, 'Gast', 'link-3', 'me')).toEqual({}); expect(ends()).toEqual([[notebook, second]]); expect(setHostWireless(doc, notebook, false, 'Gast', 'link-4', 'me')).toEqual({}); expect(ends()).toEqual([]); // Java keeps the SSID with the cable. expect(readProject(doc).hostWirelessConfigurations.get(notebook)).toEqual({ isEnabled: false, ssid: 'Gast' }); expect(setHostWireless(doc, notebook, false, '', 'link-5', 'me')).toEqual({}); expect(readProject(doc).hostWirelessConfigurations.has(notebook)).toBe(false); }); it('joins the first of two switches with its SSID', () => { const { doc, second, notebook, ends } = wlan(); const third = add(doc, 'networkSwitch'); setSwitchSsid(doc, third, 'Gast', 'me'); expect(setHostWireless(doc, notebook, true, 'Gast', 'link-1', 'me')).toEqual({ link: 'link-1' }); expect(ends()).toEqual([[notebook, second]]); }); it('joins no network without an SSID or without a switch that has it', () => { const { doc, notebook, ends } = wlan(); expect(setHostWireless(doc, notebook, true, '', 'link-1', 'me')).toEqual({}); expect(setHostWireless(doc, notebook, true, 'Nirgends', 'link-2', 'me')).toEqual({}); expect(ends()).toEqual([]); expect(host(doc, notebook)).toMatchObject({ wireless: true, ssid: 'Nirgends' }); }); it('cannot be chosen while its cable is plugged, and says why a full switch refuses it', () => { const { doc, first, notebook } = wlan(); const pc = add(doc, 'pc'); connect(doc, { node: notebook }, { node: pc }, 'cable', 'me'); expect(host(doc, notebook).wirelessPossible).toBe(false); expect(setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me')).toEqual({ error: 'cablePlugged' }); expect(host(doc, notebook).wireless).toBe(false); const other = add(doc, 'notebook'); const full = doc.getMap>(ROOTS.nodes).get(first)!; (full.get('ports') as Y.Array>).delete(1, 23); expect(connect(doc, { node: add(doc, 'pc') }, { node: first }, 'filled', 'me')).toEqual({ link: 'filled' }); expect(setHostWireless(doc, other, true, 'Schule', 'link-2', 'me')).toEqual({ error: 'noFreePort' }); expect(setHostWireless(doc, 'nobody', true, 'Schule', 'link-3', 'me')).toEqual({ error: 'unknownDevice' }); }); it("is dropped by a switch that changes its SSID, a cable to it staying (Java's panel)", () => { const { doc, first, notebook, ends } = wlan(); const pc = add(doc, 'pc'); setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me'); connect(doc, { node: pc }, { node: first }, 'cable', 'me'); expect(setSwitchSsid(doc, first, 'Schule', 'me')).toEqual({ value: 'Schule' }); expect(ends()).toHaveLength(2); expect(setSwitchSsid(doc, first, 'Ä', 'me')).toEqual({ invalid: true }); expect(ends()).toHaveLength(2); expect(setSwitchSsid(doc, first, 'Neu', 'me')).toEqual({ value: 'Neu' }); expect(ends()).toEqual([[pc, first]]); }); }); describe("a switch's and a modem's sheet", () => { it('shows and keeps the SSID, the retention time in seconds and the cloud', () => { const doc = new Y.Doc(); const hub = add(doc, 'networkSwitch', 'Switch'); expect(deviceSheet(doc, hub)).toEqual({ kind: 'networkSwitch', name: 'Switch', ssid: hub.replaceAll('-', '').slice(0, 6), retentionSeconds: 300, cloud: false }); expect(setSwitchRetention(doc, hub, ' 007 ', 'me')).toEqual({ value: '7' }); expect(setSwitchRetention(doc, hub, '-5', 'me')).toEqual({ invalid: true }); setSwitchCloud(doc, hub, true, 'me'); expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 7, cloud: true }); expect(topologyView(doc).nodes[0]!.cloud).toBe(true); expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000, showsAsCloud: true }); setSwitchCloud(doc, hub, false, 'me'); expect(readProject(doc).switchConfigurations.get(hub)).toEqual({ ssid: hub.replaceAll('-', '').slice(0, 6), retentionTimeMilliseconds: 7000 }); }); it('shows whole seconds of a retention time a file brings in milliseconds', () => { const doc = new Y.Doc(); const hub = add(doc, 'networkSwitch'); (doc.getMap>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map>).get('switch')!.set('retentionTimeMilliseconds', 1999); expect(deviceSheet(doc, hub)).toMatchObject({ retentionSeconds: 1 }); }); it('pairs a modem by its port, and renames a router', () => { const doc = new Y.Doc(); const modem = add(doc, 'remoteLink', 'Modem'); expect(deviceSheet(doc, modem)).toEqual({ kind: 'remoteLink', name: 'Modem', port: 12_345, connection: 'inProject', code: '' }); expect(setModemPort(doc, modem, '65536', 'me')).toEqual({ invalid: true }); expect(setModemPort(doc, modem, ' 80', 'me')).toEqual({ value: '80' }); expect(readProject(doc).remoteLinkConfigurations.get(modem)).toEqual({ pairIdentifier: 'java-modem-port-80', latencyMilliseconds: 20, isEnabled: true }); expect(deviceSheet(doc, modem)).toMatchObject({ port: 80 }); const router = add(doc, 'router', 'R1'); renameDevice(doc, router, 'R2', 'me'); expect(deviceSheet(doc, router)).toMatchObject({ kind: 'router', name: 'R2' }); expect(deviceSheet(doc, add(doc, 'gateway', 'Heim'))).toMatchObject({ kind: 'gateway', name: 'Heim' }); }); }); describe("a modem's connection to another project", () => { it('offers a code, joins one as typed, and goes back to the pair on its port', () => { const doc = new Y.Doc(); const modem = add(doc, 'remoteLink', 'Modem'); setModemConnection(doc, modem, 'offer', 'me'); expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'offer', code: '' }); expect(readProject(doc).remoteLinkConfigurations.get(modem)).toMatchObject({ relay: { role: 'offer' } }); expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'offer' } }); setModemConnection(doc, modem, 'join', 'me'); expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' }); expect(setModemCode(doc, modem, 'k7f-3qx', 'me')).toEqual({ value: 'K7F3QX' }); expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: 'K7F3QX' }); expect(docSnapshot(doc).remoteLinkConfigurationsByNodeId.get(modem)).toMatchObject({ relay: { role: 'join', code: 'K7F3QX' } }); // Misread characters (0, O, 1, I) and a wrong length are no code; the code stays. for (const text of ['K7F3Q0', 'K7F3QI', 'K7F3Q', 'K7F3QXY']) expect(setModemCode(doc, modem, text, 'me')).toEqual({ invalid: true }); expect(deviceSheet(doc, modem)).toMatchObject({ code: 'K7F3QX' }); setModemConnection(doc, modem, 'inProject', 'me'); expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject', code: '', port: 12_345 }); expect(readProject(doc).remoteLinkConfigurations.get(modem)).not.toHaveProperty('relay'); }); it('is each one step of the undo, and touches nothing but a modem', () => { const doc = new Y.Doc(); const modem = add(doc, 'remoteLink', 'Modem'); const notebook = add(doc, 'notebook'); const undo = createUndoManager(doc, 'me'); setModemConnection(doc, modem, 'join', 'me'); undo.stopCapturing(); setModemCode(doc, modem, 'ABCDEF', 'me'); undo.undo(); expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'join', code: '' }); undo.undo(); expect(deviceSheet(doc, modem)).toMatchObject({ connection: 'inProject' }); setModemConnection(doc, notebook, 'offer', 'me'); expect((doc.getMap>(ROOTS.nodes).get(notebook)!.get('settings') as Y.Map).has('remoteLink')).toBe(false); }); }); describe("the sheet's changes", () => { it('are each one step of the person’s undo, the wireless join with its connection', () => { const doc = new Y.Doc(); const hub = add(doc, 'networkSwitch'); const notebook = add(doc, 'notebook'); setSwitchSsid(doc, hub, 'Schule', 'setup'); const undo = createUndoManager(doc, 'me'); setHostAddress(doc, notebook, 'ipAddress', '10.0.0.1', 'me'); undo.stopCapturing(); setHostWireless(doc, notebook, true, 'Schule', 'link-1', 'me'); undo.stopCapturing(); renameDevice(doc, notebook, 'NB', 'me'); undo.stopCapturing(); undo.undo(); expect(host(doc, notebook).name).toBe('notebook'); undo.undo(); expect(host(doc, notebook).wireless).toBe(false); expect(readProject(doc).graph.links).toEqual([]); undo.undo(); expect(host(doc, notebook).ipAddress).toBe('192.168.0.10'); undo.redo(); undo.redo(); expect(host(doc, notebook)).toMatchObject({ ipAddress: '10.0.0.1', wireless: true }); expect(readProject(doc).graph.links).toHaveLength(1); }); it('are kept through a saved file', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc', 'PC-A'); const hub = add(doc, 'networkSwitch', 'S'); const modem = add(doc, 'remoteLink', 'M'); setHostAddress(doc, pc, 'ipAddress', '10.1.2.3', 'me'); setHostAddress(doc, pc, 'subnetMask', '255.255.0.0', 'me'); setHostAddress(doc, pc, 'defaultGateway', '10.1.0.1', 'me'); setHostAddress(doc, pc, 'dnsServer', '10.1.0.2', 'me'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); setIpForwarding(doc, pc, true, 'me'); setDhcpClient(doc, pc, true, 'me'); setSwitchSsid(doc, hub, 'Schule', 'me'); setSwitchRetention(doc, hub, '42', 'me'); setSwitchCloud(doc, hub, true, 'me'); setModemPort(doc, modem, '4242', 'me'); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `30000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const [pcAgain, hubAgain, modemAgain] = reopened.graph.nodes; expect(pcAgain!.hostLabelPolicy).toBe('ipAddress'); expect(reopened.deviceConfigurations.get(pcAgain!.id)).toEqual({ ipAddress: '10.1.2.3', subnetMask: '255.255.0.0', defaultGateway: '10.1.0.1', dnsServer: '10.1.0.2' }); expect(reopened.ipForwardingByNodeId.get(pcAgain!.id)).toBe(true); expect(reopened.dhcpClientConfigurations.get(pcAgain!.id)).toEqual({ isEnabled: true }); expect(reopened.switchConfigurations.get(hubAgain!.id)).toEqual({ ssid: 'Schule', retentionTimeMilliseconds: 42_000, showsAsCloud: true }); expect(reopened.remoteLinkConfigurations.get(modemAgain!.id)?.pairIdentifier).toBe('java-modem-port-4242'); }); }); const router = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as RouterSheet; const home = (doc: Y.Doc, id: string) => deviceSheet(doc, id) as HomeRouterSheet; /** Java's manual rows of the measured tables, as SheetRun added them. */ const JAVA_MANUAL = [ { destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.1.2', interfaceIPAddress: '192.168.1.1' }, { destinationNetwork: '010.020.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }, ]; /** The measured tables' routers: their cards (address, mask), gateway and manual rows. */ const TABLES: Readonly> = { gatewayInSecondNetwork: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ], gateway: '192.168.1.254', manual: true, }, gatewayInNoNetwork: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ], gateway: '172.30.0.1', manual: true, }, gatewayInBothNetworks: { cards: [ ['10.0.0.1', '255.0.0.0'], ['10.1.0.1', '255.255.0.0'], ], gateway: '10.1.0.254', manual: false, }, noGateway: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ], gateway: '', manual: true, }, threeInterfaces: { cards: [ ['10.0.0.1', '255.0.0.0'], ['192.168.1.1', '255.255.255.0'], ['172.16.5.1', '255.255.0.0'], ], gateway: '10.0.0.254', manual: false, }, }; describe("a router's sheet", () => { it("shows the forwarding table Java shows, in Java's order, its manual rows editable", () => { expect(Object.keys(java.tables).sort()).toEqual(Object.keys(TABLES).sort()); for (const [name, setup] of Object.entries(TABLES)) { const doc = new Y.Doc(); const id = add(doc, 'router', 'R', setup.cards.length); router(doc, id).interfaces.forEach((card, index) => { expect(setInterfaceAddress(doc, id, card.portId, 'ipAddress', setup.cards[index]![0], 'me')).toEqual({ value: setup.cards[index]![0] }); expect(setInterfaceAddress(doc, id, card.portId, 'subnetMask', setup.cards[index]![1], 'me')).toEqual({ value: setup.cards[index]![1] }); }); setRouterGateway(doc, id, setup.gateway, 'me'); if (setup.manual) for (const row of JAVA_MANUAL) expect('route' in addRoute(doc, id, row, 'me')).toBe(true); const table = router(doc, id).table.map((row) => ({ cells: [row.destinationNetwork, row.subnetMask, row.nextHop, row.interfaceIPAddress], manual: row.isEditable })); expect(table, name).toEqual(java.tables[name]); } }); it('takes a card’s address as Java keeps it and keeps the old one for a refused text, the first card mirrored in the device', () => { const doc = new Y.Doc(); const id = add(doc, 'router'); const [first, second] = router(doc, id).interfaces; expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', ' 010.0.0.1 ', 'me')).toEqual({ value: '10.0.0.1' }); expect(setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '.0.0.1', 'me')).toEqual({ invalid: true }); expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.255', 'me')).toEqual({ invalid: true }); expect(setInterfaceAddress(doc, id, second!.portId, 'subnetMask', '255.0.255.0', 'me')).toEqual({ value: '255.0.255.0' }); expect(setInterfaceAddress(doc, id, 'no-port', 'ipAddress', '10.0.0.1', 'me')).toEqual({ value: '10.0.0.1' }); expect(router(doc, id).interfaces.map((card) => [card.ipAddress, card.subnetMask])).toEqual([ ['10.0.0.1', '255.255.255.0'], ['192.168.0.10', '255.0.255.0'], ]); // A router keeps its own settings only with a gateway; then they follow its first card. expect(readProject(doc).deviceConfigurations.has(id)).toBe(false); expect(setRouterGateway(doc, id, '10.0.0.254', 'me')).toEqual({ value: '10.0.0.254' }); expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '' }); setInterfaceAddress(doc, id, first!.portId, 'ipAddress', '10.0.0.2', 'me'); setInterfaceAddress(doc, id, first!.portId, 'subnetMask', '255.0.0.0', 'me'); expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ ipAddress: '10.0.0.2', subnetMask: '255.0.0.0' }); expect(setRouterGateway(doc, id, 'abc', 'me')).toEqual({ invalid: true }); expect(router(doc, id).gateway).toBe('10.0.0.254'); expect(setRouterGateway(doc, id, ' ', 'me')).toEqual({ value: '' }); expect(readProject(doc).deviceConfigurations.has(id)).toBe(false); }); it('switches RIP, and shows IP forwarding on', () => { const doc = new Y.Doc(); const id = add(doc, 'router'); expect(router(doc, id)).toMatchObject({ rip: false, ipForwarding: true, gateway: '' }); setRip(doc, id, true, 'me'); expect(router(doc, id).rip).toBe(true); expect(readProject(doc).ripEnabledByNodeId.get(id)).toBe(true); setRip(doc, id, false, 'me'); expect(readProject(doc).ripEnabledByNodeId.has(id)).toBe(false); }); it('names what each card is plugged into, by its label', () => { const doc = new Y.Doc(); const id = add(doc, 'router'); const pc = add(doc, 'pc', 'PC-A'); setHostLabels(doc, pc, { ipAddress: true, macAddress: false }, 'me'); connect(doc, { node: pc }, { node: id }, 'cable', 'me'); expect(router(doc, id).interfaces.map((card) => card.connected)).toEqual([{ device: '192.168.0.10', address: '192.168.0.10', link: 'cable' }, null]); expect(router(doc, id).interfaces[0]!.macAddress).toMatch(/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/); }); }); describe("a router's manual rows", () => { /** A router with Java's one manual row of SheetRun's edits. */ function edited() { const doc = new Y.Doc(); const id = add(doc, 'router'); addRoute(doc, id, { destinationNetwork: '172.16.0.0', subnetMask: '255.255.0.0', gateway: '192.168.0.2', interfaceIPAddress: '192.168.0.10' }, 'me'); const rows = () => readProject(doc) .manualRoutesByNodeId.get(id) ?.map((row) => [row.destinationNetwork, row.subnetMask, row.gateway, row.interfaceIPAddress]) ?? []; return { doc, id, rows }; } it('take a cell as Java does where Java takes it, and keep it where Java stores null', () => { for (const [name, column, text] of [ ['leadingZeros', 'destinationNetwork', '010.0.0.0'], ['maskCell', 'subnetMask', '255.0.255.0'], ] as const) { const { doc, id, rows } = edited(); expect('value' in setRouteCell(doc, id, 0, column, text, 'me'), name).toBe(true); expect(rows(), name).toEqual(java.edits[name]!.manual); } for (const [name, text] of [ ['invalidCell', 'abc'], ['emptyCell', ''], ['dottedTwice', '10..0.0'], ] as const) { // Java: the cell becomes null. expect(java.edits[name]!.manual[0]![0], name).toBeNull(); const { doc, id, rows } = edited(); expect(setRouteCell(doc, id, 0, 'destinationNetwork', text, 'me'), name).toEqual({ invalid: true }); expect(rows()[0]![0], name).toBe('172.16.0.0'); } }); it('edit and lose the row they are asked for', () => { const { doc, id, rows } = edited(); addRoute(doc, id, JAVA_MANUAL[0]!, 'me'); expect(setRouteCell(doc, id, 1, 'gateway', '192.168.1.3', 'me')).toEqual({ value: '192.168.1.3' }); expect(rows()).toEqual([ ['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10'], ['172.16.0.0', '255.255.0.0', '192.168.1.3', '192.168.1.1'], ]); removeRoute(doc, id, 1, 'me'); expect(rows()).toEqual([['172.16.0.0', '255.255.0.0', '192.168.0.2', '192.168.0.10']]); }); it('take a new row once all four cells pass, as Java does, and lose a row', () => { const cells = { destinationNetwork: '10.9.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.9', interfaceIPAddress: '' }; let { doc, id, rows } = edited(); expect(addRoute(doc, id, cells, 'me')).toEqual({ invalid: ['interfaceIPAddress'] }); expect(rows()).toEqual(java.edits.newRowThreeCells!.manual); expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10..0.0.1' }, 'me')).toEqual({ invalid: ['interfaceIPAddress'] }); expect(rows()).toEqual(java.edits.newRowInvalidCell!.manual); expect(addRoute(doc, id, { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ route: { ...cells, interfaceIPAddress: '10.0.0.1' } }); expect(rows()).toEqual(java.edits.newRowFourCells!.manual); expect(addRoute(doc, 'nobody', { ...cells, interfaceIPAddress: '10.0.0.1' }, 'me')).toEqual({ invalid: [] }); ({ doc, id, rows } = edited()); removeRoute(doc, id, 5, 'me'); removeRoute(doc, id, 0, 'me'); expect(rows()).toEqual(java.edits.removeRow!.manual); expect((doc.getMap>(ROOTS.nodes).get(id)!.get('settings') as Y.Map).has('routes')).toBe(false); // Java keeps one new row at a time; the sheet's draft is one row too. expect(java.edits.newRowTwice!.rowsShown).toBe(2); }); }); describe("a home router's sheet", () => { it("shows its WAN and LAN cards, keeps the WAN card in the device's settings, and leaves its DHCP server running", () => { expect(java.flows).toMatchObject({ homeRouterDhcpClient: true, homeRouterDhcpClientStopsServer: false }); const doc = new Y.Doc(); const id = add(doc, 'gateway', 'Heim'); const sheet = home(doc, id); expect([sheet.wan.ipAddress, sheet.wan.subnetMask, sheet.lan.ipAddress, sheet.lan.subnetMask, sheet.gateway, sheet.ipForwarding, sheet.dhcpClient]).toEqual([ '42.0.0.10', '255.0.0.0', '192.168.0.10', '255.255.255.0', '', true, false, ]); expect(setInterfaceAddress(doc, id, sheet.wan.portId, 'ipAddress', '042.1.2.3', 'me')).toEqual({ value: '42.1.2.3' }); expect(setInterfaceAddress(doc, id, sheet.lan.portId, 'subnetMask', '255.255.255.128', 'me')).toEqual({ value: '255.255.255.128' }); expect(setRouterGateway(doc, id, '42.0.0.1', 'me')).toEqual({ value: '42.0.0.1' }); expect(readProject(doc).deviceConfigurations.get(id)).toEqual({ ipAddress: '42.1.2.3', subnetMask: '255.0.0.0', defaultGateway: '42.0.0.1', dnsServer: '' }); // Unlike a router, a home router keeps its own settings without a gateway. setRouterGateway(doc, id, '', 'me'); expect(readProject(doc).deviceConfigurations.get(id)).toMatchObject({ defaultGateway: '' }); const settings = doc.getMap>(ROOTS.nodes).get(id)!.get('settings') as Y.Map; const server = new Y.Map(); server.set('isActive', true); settings.set('dhcpServer', server); setDhcpClient(doc, id, true, 'me'); expect(home(doc, id).dhcpClient).toBe(true); expect(server.get('isActive')).toBe(true); }); it('keeps everything through a saved file, with the router’s rows and RIP', () => { const doc = new Y.Doc(); const id = add(doc, 'router', 'R', 3); const homeId = add(doc, 'gateway', 'Heim'); const cards = router(doc, id).interfaces; setInterfaceAddress(doc, id, cards[2]!.portId, 'ipAddress', '172.16.5.1', 'me'); setRouterGateway(doc, id, '192.168.0.254', 'me'); setRip(doc, id, true, 'me'); addRoute(doc, id, JAVA_MANUAL[1]!, 'me'); setInterfaceAddress(doc, homeId, home(doc, homeId).lan.portId, 'ipAddress', '192.168.7.1', 'me'); setRouterGateway(doc, homeId, '42.0.0.1', 'me'); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `40000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const [routerAgain, homeAgain] = reopened.graph.nodes; const address = (node: typeof routerAgain, index: number) => reopened.interfaceConfigurations.get(`${node!.id}/${node!.ports[index]!.id}`)?.ipAddress; expect(address(routerAgain, 2)).toBe('172.16.5.1'); expect(reopened.deviceConfigurations.get(routerAgain!.id)?.defaultGateway).toBe('192.168.0.254'); expect(reopened.ripEnabledByNodeId.get(routerAgain!.id)).toBe(true); expect(reopened.manualRoutesByNodeId.get(routerAgain!.id)).toEqual([{ destinationNetwork: '10.20.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }]); expect(address(homeAgain, 1)).toBe('192.168.7.1'); expect(reopened.deviceConfigurations.get(homeAgain!.id)?.defaultGateway).toBe('42.0.0.1'); }); }); describe("a DHCP server's sheet", () => { /** The devices of SheetRun's DHCP flows: a host 192.168.0.10/24 with gateway and DNS, a home router; the server with known settings. */ function servers() { const doc = new Y.Doc(); const pc = add(doc, 'pc'); setHostAddress(doc, pc, 'defaultGateway', '192.168.0.254', 'me'); setHostAddress(doc, pc, 'dnsServer', '192.168.0.253', 'me'); const homeId = add(doc, 'gateway'); for (const id of [pc, homeId]) { setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me'); setDhcpServerAddress(doc, id, 'upperBoundIPAddress', '192.168.0.200', 'me'); setDhcpServerAddress(doc, id, 'gatewayIPAddress', '10.0.0.1', 'me'); setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '10.0.0.2', 'me'); } return { doc, pc, homeId }; } it("shows what Java's dialog shows: the device's gateway and DNS server unless its own, a home router's LAN address", () => { const { doc, pc, homeId } = servers(); const shown = (sheet: ReturnType['dhcpServer']) => ({ tfUntergrenze: [sheet.lowerBoundIPAddress, true], tfObergrenze: [sheet.upperBoundIPAddress, true], tfNetzmaske: [sheet.subnetMask, false], tfGateway: [sheet.gateway, sheet.gatewayEditable], tfDNSServer: [sheet.dnsServer, sheet.dnsServerEditable], }); expect(shown(host(doc, pc).dhcpServer)).toEqual(java.dhcp.dhcpHostShown); expect(shown(home(doc, homeId).dhcpServer)).toEqual(java.dhcp.dhcpHomeShown); expect(home(doc, homeId).dhcpServer.ownSettingsOffered).toBe(false); // A home router's choice has no effect (Java's dialog keeps the flag, the gateway stays its LAN address). setDhcpServerFlag(doc, homeId, 'useOwnSettings', true, 'me'); expect(home(doc, homeId).dhcpServer).toMatchObject({ useOwnSettings: true, gateway: '192.168.0.10', gatewayEditable: false }); const bare = add(doc, 'pc'); expect([host(doc, bare).dhcpServer.gateway, host(doc, bare).dhcpServer.dnsServer]).toEqual(java.dhcp.hostWithoutGatewayShown); setDhcpServerFlag(doc, pc, 'useOwnSettings', true, 'me'); expect(host(doc, pc).dhcpServer).toMatchObject({ gateway: '10.0.0.1', gatewayEditable: true, dnsServer: '10.0.0.2', dnsServerEditable: true, useOwnSettings: true }); setDhcpServerFlag(doc, pc, 'isActive', true, 'me'); expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({ isActive: true, useOwnSettings: true, lowerBoundIPAddress: '192.168.0.100' }); }); it("keeps a static assignment once both cells pass and its MAC address is free, unlike Java's silent drops", () => { const { doc, pc } = servers(); // Java's add button takes the duplicate and the "_F" too (musterMacAddress); its server drops the duplicate on saving. expect(java.dhcp.addable).toEqual([true, true, true, false, true, false]); const rows = [ ['aa:bb:cc:dd:ee:01', '192.168.0.50'], ['AA:BB:CC:DD:EE:01', '192.168.0.51'], ['AA:BB:CC:DD:EE:_F', '192.168.0.52'], ['AA:BB:CC:DD:EE:02', '192.168.0.256'], ['AA:BB:CC:DD:EE:03', '010.0.0.1'], ['AABBCCDDEE04', '192.168.0.54'], ] as const; const outcomes = rows.map(([macAddress, ipAddress], index) => { const result = addDhcpStaticAssignment(doc, pc, { macAddress, ipAddress }, `assignment-${index}`, 'me'); return 'assignment' in result ? 'taken' : 'error' in result ? result.error : `invalid ${result.invalid.join(' ')}`; }); expect(outcomes).toEqual(['taken', 'macTaken', 'invalid macAddress', 'invalid ipAddress', 'taken', 'invalid macAddress']); // Java kept the first, the "_F" its pattern lets through, and "010.0.0.1" as typed; here upper case and IP.ipCheck's form. expect(java.dhcp.staticAssignments).toEqual([ ['aa:bb:cc:dd:ee:01', '192.168.0.50'], ['AA:BB:CC:DD:EE:_F', '192.168.0.52'], ['AA:BB:CC:DD:EE:03', '010.0.0.1'], ]); expect(host(doc, pc).dhcpServer.staticAssignments).toEqual([ { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }, { macAddress: 'AA:BB:CC:DD:EE:03', ipAddress: '10.0.0.1' }, ]); expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:01', 'me')).toEqual({ invalid: true }); expect(setDhcpStaticCell(doc, pc, 1, 'macAddress', 'aa:bb:cc:dd:ee:09', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:09' }); expect(setDhcpStaticCell(doc, pc, 0, 'ipAddress', '192.168.0.60', 'me')).toEqual({ value: '192.168.0.60' }); expect(setDhcpStaticCell(doc, pc, 5, 'ipAddress', '192.168.0.61', 'me')).toEqual({ value: '192.168.0.61' }); removeDhcpStaticAssignment(doc, pc, 7, 'me'); removeDhcpStaticAssignment(doc, pc, 0, 'me'); expect(readProject(doc).dhcpServerConfigurations.get(pc)?.staticAssignments).toEqual([{ id: 'assignment-4', macAddress: 'AA:BB:CC:DD:EE:09', ipAddress: '10.0.0.1' }]); expect(addDhcpStaticAssignment(doc, 'nobody', { macAddress: 'AA:BB:CC:DD:EE:0A', ipAddress: '10.0.0.3' }, 'x', 'me')).toEqual({ invalid: [] }); }); it("compares MAC addresses ignoring case, as Java does, a file's lower case too, and lets a row keep its own", () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me'); // A file keeps a MAC address as Java's dialog took it, in lower case. const entry = ((doc.getMap>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map>).get('dhcpServer')!.get('staticAssignments') as Y.Array>).get(0); entry.set('macAddress', 'aa:bb:cc:dd:ee:01'); expect(addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.6' }, 'b', 'me')).toEqual({ error: 'macTaken' }); expect(setDhcpStaticCell(doc, pc, 0, 'macAddress', 'AA:BB:CC:DD:EE:01', 'me')).toEqual({ value: 'AA:BB:CC:DD:EE:01' }); }); it('takes a static assignment on a device without a server in the document, made as Java makes it', () => { const doc = new Y.Doc(); const pc = add(doc, 'pc'); expect('assignment' in addDhcpStaticAssignment(doc, pc, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }, 'a', 'me')).toBe(true); expect(readProject(doc).dhcpServerConfigurations.get(pc)).toMatchObject({ isActive: false, lowerBoundIPAddress: '0.0.0.0', staticAssignments: [{ id: 'a', macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '10.0.0.5' }], }); }); }); describe("a home router's port forwarding", () => { const JAVA_ROWS = () => java.forwarding.rows.map(([protocolValue, publicPortValue, lanIPAddress, lanPortValue]) => ({ protocolValue, publicPortValue, lanIPAddress, lanPortValue })); it('takes a row once all four cells pass, where Java keeps any row and its firmware forwards only the valid ones', () => { const doc = new Y.Doc(); const id = add(doc, 'gateway'); const outcomes = JAVA_ROWS().map((row) => { const result = addPortForwarding(doc, id, row, 'me'); return 'row' in result ? 'taken' : result.invalid.join(' '); }); // Java's firmware forwards the first only ("17" it would take, but not the leading zero of "05353"). expect(java.forwarding.valid).toEqual([true, false, false, false, false]); expect(outcomes).toEqual(['taken', 'publicPortValue', 'publicPortValue', 'protocolValue lanIPAddress', 'protocolValue lanPortValue']); expect(home(doc, id).portForwarding).toEqual([{ ...JAVA_ROWS()[0]!, forwarded: true }]); expect(addPortForwarding(doc, 'nobody', JAVA_ROWS()[0]!, 'me')).toEqual({ invalid: [] }); // Java's dialog offers TCP and UDP; Java's firmware would take "tcp", "6" or "17" from a file. for (const protocolValue of ['tcp', '6', 'ICMP']) expect(addPortForwarding(doc, id, { ...JAVA_ROWS()[0]!, protocolValue }, 'me')).toEqual({ invalid: ['protocolValue'] }); }); it("marks the rows a file brought that Java's firmware would not forward, and edits and removes rows", () => { const doc = new Y.Doc(); const id = add(doc, 'gateway'); const settings = doc.getMap>(ROOTS.nodes).get(id)!.get('settings') as Y.Map; const list = new Y.Array>(); settings.set('portForwarding', list); list.push(JAVA_ROWS().map((row) => toY({ ...row }) as Y.Map)); expect(home(doc, id).portForwarding.map((row) => row.forwarded)).toEqual(java.forwarding.valid); expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', '0053', 'me')).toEqual({ invalid: true }); expect(setPortForwardingCell(doc, id, 1, 'publicPortValue', ' 53', 'me')).toEqual({ value: '53' }); expect(setPortForwardingCell(doc, id, 1, 'protocolValue', 'ICMP', 'me')).toEqual({ invalid: true }); expect(setPortForwardingCell(doc, id, 0, 'protocolValue', 'UDP', 'me')).toEqual({ value: 'UDP' }); expect(home(doc, id).portForwarding.map((row) => [row.protocolValue, row.publicPortValue, row.forwarded])).toEqual([ ['UDP', '80', true], ['UDP', '53', true], ['TCP', 'abc', false], ['tcp', '443', false], ['17', '5353', false], ]); for (const index of [9, 4, 3, 2, 1]) removePortForwarding(doc, id, index, 'me'); expect(readProject(doc).portForwardingRowsByNodeId.get(id)).toHaveLength(1); removePortForwarding(doc, id, 0, 'me'); expect(settings.has('portForwarding')).toBe(false); }); it('keeps the DHCP server and the port forwarding through a saved file', () => { const doc = new Y.Doc(); const id = add(doc, 'gateway'); setDhcpServerAddress(doc, id, 'lowerBoundIPAddress', '192.168.0.100', 'me'); setDhcpServerAddress(doc, id, 'dnsServerIPAddress', '8.8.8.8', 'me'); setDhcpServerFlag(doc, id, 'isActive', true, 'me'); addDhcpStaticAssignment(doc, id, { macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }, 'a', 'me'); addPortForwarding(doc, id, JAVA_ROWS()[0]!, 'me'); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `50000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const again = reopened.graph.nodes[0]!.id; expect(reopened.dhcpServerConfigurations.get(again)).toMatchObject({ isActive: true, lowerBoundIPAddress: '192.168.0.100', dnsServerIPAddress: '8.8.8.8', staticAssignments: [{ macAddress: 'AA:BB:CC:DD:EE:01', ipAddress: '192.168.0.50' }], }); expect(reopened.portForwardingRowsByNodeId.get(again)).toEqual([JAVA_ROWS()[0]]); }); }); describe("a router's firewall", () => { const JAVA_PROTOCOLS: Readonly> = { [-1]: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp' }; const COLUMNS: Readonly> = { 1: 'sourceIPAddress', 2: 'sourceSubnetMask', 3: 'destinationIPAddress', 4: 'destinationSubnetMask', 6: 'port' }; const ruleOfJava = (rule: JavaFirewallRule) => ({ sourceIPAddress: rule.srcIP, sourceSubnetMask: rule.srcMask, destinationIPAddress: rule.destIP, destinationSubnetMask: rule.destMask, port: rule.port, protocol: JAVA_PROTOCOLS[rule.protocol], action: rule.action === 1 ? 'accept' : 'drop', }); function firewalled() { const doc = new Y.Doc(); const id = add(doc, 'router'); const rules = () => router(doc, id).firewall.rules; return { doc, id, rules }; } it("starts as Java's, and makes a new rule as Java's does", () => { const { doc, id, rules } = firewalled(); const [isActive, policy, dropIcmp, filterSyn] = java.firewall.newFirewall; expect(router(doc, id).firewall).toEqual({ isActive, defaultPolicy: policy === 1 ? 'accept' : 'drop', dropIcmp, filterSynSegmentsOnly: filterSyn, rules: [] }); expect(addFirewallRule(doc, id, 'me')).toBe(0); expect(rules()).toEqual([ruleOfJava(java.firewall.newRule)]); expect(addFirewallRule(doc, 'nobody', 'me')).toBeUndefined(); }); it("takes a cell by Java's checks, never a value they refuse (Java's table keeps it in the rule)", () => { // Java: what its rule holds after the cell is taken, and whether the rule then passes its own check. expect(java.firewall.cells.filter((each) => each.column in COLUMNS).map((each) => [each.column, each.input, ruleOfJava(each.rule)[COLUMNS[each.column]!], each.valid])).toEqual([ [1, '10.0.0.0', '10.0.0.0', true], [1, 'abc', 'abc', false], [1, '.0.0.1', '.0.0.1', true], [1, '999.999.999.999', '999.999.999.999', true], [1, '010.0.0.0', '010.0.0.0', true], [1, ' ', ' ', true], [2, '255.255.0.0', '255.255.0.0', true], [2, '255.0.255.0', '255.0.255.0', false], [2, '0.0.0.0', '0.0.0.0', false], [2, '255.255.255.255', '255.255.255.255', true], [2, '0255.255.0.0', '0255.255.0.0', true], [3, '192.168.0.1', '192.168.0.1', true], [3, '999.999.999.999', '999.999.999.999', false], [4, '255.255.255.0', '255.255.255.0', true], [6, '80', 80, true], [6, 'abc', -1, true], [6, '0', 0, false], [6, '080', 80, true], [6, '', -1, true], [6, '65536', 65_536, false], ]); // Here: what the rule takes, or that it takes nothing. Not adopted: ".0.0.1" (no address), "abc" as all ports, "080" (musterPort). const ours = java.firewall.cells .filter((each) => each.column in COLUMNS) .map((each) => { const { doc, id, rules } = firewalled(); addFirewallRule(doc, id, 'me'); const result = setFirewallRuleCell(doc, id, 0, COLUMNS[each.column]!, each.input, 'me'); return 'invalid' in result ? 'refused' : rules()[0]![COLUMNS[each.column]!]; }); expect(ours).toEqual([ '10.0.0.0', 'refused', 'refused', '999.999.999.999', '10.0.0.0', '', '255.255.0.0', 'refused', 'refused', '255.255.255.255', '255.255.0.0', '192.168.0.1', 'refused', '255.255.255.0', 80, 'refused', 'refused', 'refused', -1, 'refused', ]); }); it("chooses a rule's protocol and action as Java's lists do", () => { const choices = java.firewall.cells.filter((each) => !(each.column in COLUMNS)).map((each) => ruleOfJava(each.rule)); expect(choices.map((each) => [each.protocol, each.action])).toEqual([ ['udp', 'drop'], ['all', 'drop'], ['tcp', 'accept'], ]); const { doc, id, rules } = firewalled(); addFirewallRule(doc, id, 'me'); setFirewallRuleChoice(doc, id, 0, { protocol: 'udp' }, 'me'); setFirewallRuleChoice(doc, id, 0, { action: 'accept' }, 'me'); expect(rules()[0]).toMatchObject({ protocol: 'udp', action: 'accept' }); setFirewallRuleChoice(doc, id, 5, { protocol: 'all' }, 'me'); expect(rules()).toHaveLength(1); }); it("moves and removes rules as Java's buttons do, the first going no higher and the last no lower", () => { const { doc, id, rules } = firewalled(); for (const source of ['1.1.1.1', '2.2.2.2', '3.3.3.3']) setFirewallRuleCell(doc, id, addFirewallRule(doc, id, 'me')!, 'sourceIPAddress', source, 'me'); // Java's buttons pass the row's number (1 for the first); here its place. const ours = java.firewall.moves.map(([step, number]) => { const answer = step === 'delete' ? (removeFirewallRule(doc, id, number - 1, 'me'), null) : moveFirewallRule(doc, id, number - 1, step === 'up' ? -1 : 1, 'me'); return [step, number, answer, rules().map((rule) => rule.sourceIPAddress)]; }); expect(ours).toEqual(java.firewall.moves); expect(moveFirewallRule(doc, id, 7, 1, 'me')).toBe(false); removeFirewallRule(doc, id, 7, 'me'); expect(rules()).toHaveLength(2); }); it('switches its flags and its default, and keeps all through a saved file', () => { const { doc, id } = firewalled(); const homeId = add(doc, 'gateway'); for (const device of [id, homeId]) { setFirewallFlag(doc, device, 'isActive', true, 'me'); setFirewallFlag(doc, device, 'dropIcmp', true, 'me'); setFirewallFlag(doc, device, 'filterSynSegmentsOnly', false, 'me'); setFirewallDefaultPolicy(doc, device, 'accept', 'me'); addFirewallRule(doc, device, 'me'); setFirewallRuleCell(doc, device, 0, 'destinationIPAddress', '10.0.0.0', 'me'); setFirewallRuleCell(doc, device, 0, 'destinationSubnetMask', '255.0.0.0', 'me'); setFirewallRuleCell(doc, device, 0, 'port', '80', 'me'); } expect(home(doc, homeId).firewall).toMatchObject({ isActive: true, dropIcmp: true, filterSynSegmentsOnly: false, defaultPolicy: 'accept' }); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `60000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); for (const node of reopened.graph.nodes) { expect(reopened.firewallConfigurationsByNodeId.get(node.id)).toMatchObject({ isActive: true, dropIcmp: true, filterSynSegmentsOnly: false, defaultPolicy: 'accept', rules: [{ sourceIPAddress: '', destinationIPAddress: '10.0.0.0', destinationSubnetMask: '255.0.0.0', port: 80, protocol: 'tcp', action: 'drop' }], }); } }); }); describe("a router's connections", () => { /** SheetRun's network: router R with three cards, PC A plugged into the first, switch S into the second. */ function network() { const doc = new Y.Doc(); const id = add(doc, 'router', 'R', 3); router(doc, id).interfaces.forEach((card, index) => setInterfaceAddress(doc, id, card.portId, 'ipAddress', `10.${index}.0.1`, 'me')); const pc = add(doc, 'pc', 'A'); setHostAddress(doc, pc, 'ipAddress', '10.0.0.2', 'me'); const sw = add(doc, 'networkSwitch', 'S'); expect(connect(doc, { node: pc }, { node: id }, 'cable-a', 'me')).toEqual({ link: 'cable-a' }); expect(connect(doc, { node: id, port: router(doc, id).interfaces[1]!.portId }, { node: sw }, 'cable-s', 'me')).toEqual({ link: 'cable-s' }); return { doc, id, pc, sw }; } /** What Java's dialog would show of the router, with the card marked. */ function shown(doc: Y.Doc, id: string, marked: number | null) { const cards = router(doc, id).interfaces; const pad = (each: T[], blank: T) => [...each, ...Array(8 - each.length).fill(blank)]; return { local: pad(cards.map((card, index) => `NIC ${index + 1}: ${card.ipAddress}`), ''), remote: pad(cards.map((card) => (card.connected === null ? '' : card.connected.address === null ? card.connected.device : `${card.connected.device} (${card.connected.address})`)), ''), buttons: pad(cards.map((card, index) => (index === marked ? 'marked' : card.connected === null ? 'free' : 'plugged')), 'off'), add: cards.length < 8, remove: cards.length > 1, cards: cards.map((card) => [card.ipAddress, card.subnetMask, card.connected?.device ?? '']), cables: doc.getMap(ROOTS.links).size, }; } it('swap, add and remove cards as Java’s dialog does, click by click, down to the last card', () => { const { doc, id } = network(); let marked: number | null = null; const steps = java.connections.steps; const last = steps.findIndex((step) => step.cards.length === 1); for (const [index, step] of steps.slice(0, last + 1).entries()) { if (step.click.startsWith('c')) { const next = connectionsClick(marked, Number(step.click.slice(1))); if (next.swap !== undefined) { const ports = router(doc, id).interfaces.map((card) => card.portId); swapRouterCables(doc, id, ports[next.swap[0]]!, ports[next.swap[1]]!, 'me'); } marked = next.marked; } else if (step.click === '+') { // Java's "+" is greyed out at eight cards; its click does nothing then. expect(addRouterInterface(doc, id, newId(), 'me'), `step ${index}`).toBe(steps[index - 1]!.add); marked = null; } else if (step.click === '-') { expect(removeRouterInterface(doc, id, 'me'), `step ${index}`).toBe(true); marked = null; } const { local, remote, buttons, add: addable, remove, cards, cables } = step; // Java's "-" stays open at one card; here the last card stays (below). expect(shown(doc, id, marked), `step ${index} ${step.click}`).toEqual({ local, remote, buttons, add: addable, remove: index === last ? false : remove, cards, cables }); } expect(steps[last]!.remove).toBe(true); expect(steps.at(-1)!.cards).toEqual([]); // Java's router without a card keeps no gateway; the one with a card does. expect([steps[last]!.gateway, steps.at(-1)!.gateway]).toEqual(['10.0.0.254', null]); expect(steps[last]!.panelError).toBeNull(); }); it('keep the last card, with its cable, and its gateway', () => { const doc = new Y.Doc(); const id = add(doc, 'router', 'R', 2); const pc = add(doc, 'pc', 'A'); connect(doc, { node: pc }, { node: id }, 'cable', 'me'); setRouterGateway(doc, id, '192.168.0.254', 'me'); expect(removeRouterInterface(doc, id, 'me')).toBe(true); expect(removeRouterInterface(doc, id, 'me')).toBe(false); expect(router(doc, id).interfaces).toHaveLength(1); expect(router(doc, id).interfaces[0]!.connected).toMatchObject({ device: 'A', link: 'cable' }); expect(router(doc, id).gateway).toBe('192.168.0.254'); }); it('remove the last card’s cable with it, and free the other device’s port', () => { const { doc, id, sw } = network(); const ports = router(doc, id).interfaces.map((card) => card.portId); expect(swapRouterCables(doc, id, ports[1]!, ports[2]!, 'me')).toBe(true); expect(removeRouterInterface(doc, id, 'me')).toBe(true); expect([...doc.getMap>(ROOTS.links).keys()]).toEqual(['cable-a']); expect(java.connections.switchPortsFree).toBe(true); // The switch's port takes another cable. const pc = add(doc, 'pc', 'B'); expect(connect(doc, { node: pc }, { node: sw }, 'cable-b', 'me')).toEqual({ link: 'cable-b' }); expect(java.connections.hostPortFree).toBe(true); }); it('give a new card Java’s address and mask, its own MAC address, the next label and no cable', () => { const { doc, id } = network(); expect(addRouterInterface(doc, id, 'port-4', 'me')).toBe(true); const cards = router(doc, id).interfaces; expect(cards[3]).toMatchObject({ portId: 'port-4', ipAddress: '192.168.0.10', subnetMask: '255.255.255.0', connected: null }); expect(new Set(cards.map((card) => card.macAddress)).size).toBe(4); const ports = (doc.getMap>(ROOTS.nodes).get(id)!.get('ports') as Y.Array>).toArray(); expect(ports.map((port) => port.get('label'))).toEqual(['rt1', 'rt2', 'rt3', 'rt4']); expect(ports[3]!.get('occupied')).toBe(false); }); it('keep each cable’s identity and devices when cards swap, and move nothing between free cards', () => { const { doc, id, pc, sw } = network(); const ports = router(doc, id).interfaces.map((card) => card.portId); const ends = () => [...doc.getMap>(ROOTS.links).entries()].map(([linkId, link]) => [linkId, link.get('sourceNode'), link.get('sourcePort'), link.get('targetNode'), link.get('targetPort')]); const before = ends(); expect(swapRouterCables(doc, id, ports[0]!, ports[1]!, 'me')).toBe(true); // The PC plugged first (the router is the cable's target), the router into the switch (its source). expect(ends()).toEqual([ ['cable-a', pc, before[0]![2], id, ports[1]], ['cable-s', id, ports[0], sw, before[1]![4]], ]); addRouterInterface(doc, id, 'port-4', 'me'); const now = ends(); expect(swapRouterCables(doc, id, ports[2]!, 'port-4', 'me')).toBe(false); expect(ends()).toEqual(now); }); it('refuse a card twice, a card not the router’s, and devices that are no router', () => { const { doc, id, pc } = network(); const ports = router(doc, id).interfaces.map((card) => card.portId); const updates: unknown[] = []; doc.on('update', (update) => updates.push(update)); expect(swapRouterCables(doc, id, ports[0]!, ports[0]!, 'me')).toBe(false); expect(swapRouterCables(doc, id, ports[0]!, 'no-port', 'me')).toBe(false); expect(swapRouterCables(doc, id, 'no-port', ports[0]!, 'me')).toBe(false); const pcPort = (doc.getMap>(ROOTS.nodes).get(pc)!.get('ports') as Y.Array>).get(0).get('id') as string; expect(swapRouterCables(doc, id, ports[2]!, pcPort, 'me')).toBe(false); expect(swapRouterCables(doc, 'nobody', ports[0]!, ports[1]!, 'me')).toBe(false); const homeId = add(doc, 'gateway'); const [wan, lan] = [home(doc, homeId).wan.portId, home(doc, homeId).lan.portId]; expect(swapRouterCables(doc, homeId, wan, lan, 'me')).toBe(false); expect(addRouterInterface(doc, homeId, 'port-x', 'me')).toBe(false); expect(removeRouterInterface(doc, homeId, 'me')).toBe(false); expect(addRouterInterface(doc, pc, 'port-x', 'me')).toBe(false); expect(removeRouterInterface(doc, 'nobody', 'me')).toBe(false); const before = updates.length; expect(before).toBeGreaterThan(0); for (let more = 3; more < 8; more++) expect(addRouterInterface(doc, id, `port-${more}`, 'me')).toBe(true); expect(addRouterInterface(doc, id, 'port-9', 'me')).toBe(false); expect(router(doc, id).interfaces).toHaveLength(8); expect(updates).toHaveLength(before + 5); }); it('click as Java’s dialog does: mark, unmark, swap', () => { expect(connectionsClick(null, 2)).toEqual({ marked: 2 }); expect(connectionsClick(2, 2)).toEqual({ marked: null }); expect(connectionsClick(2, 0)).toEqual({ marked: null, swap: [2, 0] }); }); it('are each one step of the person’s undo, and go into the file', () => { const { doc, id } = network(); const undo = createUndoManager(doc, 'me'); const ports = router(doc, id).interfaces.map((card) => card.portId); undo.stopCapturing(); addRouterInterface(doc, id, 'port-4', 'me'); undo.stopCapturing(); swapRouterCables(doc, id, ports[0]!, 'port-4', 'me'); undo.stopCapturing(); removeRouterInterface(doc, id, 'me'); expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '']); undo.undo(); expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['', 'S', '', 'A']); undo.undo(); expect(router(doc, id).interfaces.map((card) => card.connected?.device ?? '')).toEqual(['A', 'S', '', '']); swapRouterCables(doc, id, ports[0]!, 'port-4', 'me'); let counter = 0; const reopened = openProjectArchive(saveProjectArchive(readProject(doc)).archive, () => `70000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const again = reopened.graph.nodes.find((node) => node.displayName === 'R')!; expect(again.ports.map((port) => port.label)).toEqual(['rt1', 'rt2', 'rt3', 'rt4']); const plugged = reopened.graph.links.flatMap((link) => [link.sourcePortId, link.targetPortId]); expect(again.ports.map((port) => plugged.includes(port.id))).toEqual([false, true, false, true]); }); it('name the address of the card a cable plugs into: a computer’s, a router’s, none for a switch', () => { const { doc, id } = network(); const other = add(doc, 'router', 'R2'); setInterfaceAddress(doc, other, router(doc, other).interfaces[1]!.portId, 'ipAddress', '10.2.0.2', 'me'); connect(doc, { node: id, port: router(doc, id).interfaces[2]!.portId }, { node: other, port: router(doc, other).interfaces[1]!.portId }, 'cable-r', 'me'); const homeId = add(doc, 'gateway', 'H'); const modem = add(doc, 'remoteLink', 'M'); addRouterInterface(doc, id, 'port-4', 'me'); addRouterInterface(doc, id, 'port-5', 'me'); connect(doc, { node: homeId, port: home(doc, homeId).lan.portId }, { node: id, port: 'port-4' }, 'cable-h', 'me'); connect(doc, { node: modem }, { node: id, port: 'port-5' }, 'cable-m', 'me'); expect(router(doc, id).interfaces.map((card) => card.connected && [card.connected.device, card.connected.address])).toEqual([ ['A', '10.0.0.2'], ['S', null], ['R2', '10.2.0.2'], ['H', '192.168.0.10'], ['M', null], ]); }); });