Measured first: SheetRun drives FILIUS' firewall dialog down to its table editor (Swing's own end of an edit). Java's table writes a cell into the rule before it checks it, so that the firewall holds "abc", a mask of 255.0.255.0 or port 0 while the table shows the old value, and reads a port that is no number as all ports. Here nothing the check refuses reaches the rule. - doc: the three switches, the default action, rules (new as Java's, cells by Java's checks: masks of ones then zeros, the directly connected marker as a source only, an empty port for all ports; protocol, action, moved up or down, removed) - editor: the firewall as a page of the router's and home router's sheet, with Java's explanations; the general tab named so (Java: "network interfaces") Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1092 lines
54 KiB
TypeScript
1092 lines
54 KiB
TypeScript
// 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,
|
||
readProject,
|
||
renameDevice,
|
||
ROOTS,
|
||
setDhcpClient,
|
||
setHostAddress,
|
||
setHostLabels,
|
||
setHostWireless,
|
||
setIpForwarding,
|
||
setModemPort,
|
||
setSwitchCloud,
|
||
setSwitchRetention,
|
||
setSwitchSsid,
|
||
addRoute,
|
||
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<Record<string, readonly JavaRow[]>>;
|
||
readonly edits: Readonly<Record<string, { readonly manual: readonly (readonly (string | null)[])[]; readonly rowsShown?: number }>>;
|
||
readonly dhcp: {
|
||
readonly dhcpHostShown: Readonly<Record<string, readonly [string, boolean]>>;
|
||
readonly dhcpHomeShown: Readonly<Record<string, readonly [string, boolean]>>;
|
||
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[]])[];
|
||
};
|
||
};
|
||
|
||
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<Record<string, FieldRule>> = {
|
||
'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<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<unknown>;
|
||
const server = new Y.Map<unknown>();
|
||
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<Y.Map<unknown>>(ROOTS.nodes).get(first)!;
|
||
(full.get('ports') as Y.Array<Y.Map<unknown>>).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<Y.Map<unknown>>(ROOTS.nodes).get(hub)!.get('settings') as Y.Map<Y.Map<unknown>>).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 });
|
||
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("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<Record<string, { readonly cards: readonly (readonly [string, string])[]; readonly gateway: string; readonly manual: boolean }>> = {
|
||
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', 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<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>).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<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
|
||
const server = new Y.Map<unknown>();
|
||
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<typeof host>['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<Y.Map<unknown>>(ROOTS.nodes).get(pc)!.get('settings') as Y.Map<Y.Map<unknown>>).get('dhcpServer')!.get('staticAssignments') as Y.Array<Y.Map<unknown>>).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<Y.Map<unknown>>(ROOTS.nodes).get(id)!.get('settings') as Y.Map<unknown>;
|
||
const list = new Y.Array<Y.Map<unknown>>();
|
||
settings.set('portForwarding', list);
|
||
list.push(JAVA_ROWS().map((row) => toY({ ...row }) as Y.Map<unknown>));
|
||
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<Record<number, string>> = { [-1]: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp' };
|
||
const COLUMNS: Readonly<Record<number, FirewallRuleColumn>> = { 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' }],
|
||
});
|
||
}
|
||
});
|
||
});
|