// The editor model built from a .fls file must be what the Swift import // builds (corpus.swift.json, `state`, tools/swift-oracle) — on every corpus // file Swift reads completely. On the others Swift loses devices (plan §13, // M2); there Java is the reference, checked in topology.test.ts. import { createHash } from 'node:crypto'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { defaultDisplayName, defaultSwitchConfiguration, firewallConfiguration, firewallRule, FIREWALL_PROTOCOL, interfaceKey, deepEqual, NetworkRuntimeEngine, portSnapshot, topologySnapshot, VirtualFileSystem, type EmailAddress, type EmailMessage, type Id, type NodeKind, type VirtualFileEntry, } from '@mdg-filius/core'; import { decodeJavaBeans, importProject, openProjectArchive, projectSnapshot, projectWithSimulationChanges, readFlsArchive, saveProjectArchive, readTopology, type FlsApplication, type JavaEmail, type JavaEmailAccount, type FlsFileTree, type FlsNode, type FlsTopology, type ImportedProject, } from '../src/index.js'; import { EXPECTED_WARNINGS } from './expectedWarnings.js'; const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); interface SwiftSummary { skipped?: number; warnings?: string[]; state?: unknown; } const swiftView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.swift.json'), 'utf8')) as Record; function flsFiles(directory: string): string[] { return readdirSync(directory) .flatMap((name) => { const path = join(directory, name); return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : []; }) .sort(); } /** Ids a test can read: node-1, node-2, … in the order they are made. */ function counter(): () => Id { let next = 0; return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`; } function importFile(file: string): ImportedProject { const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml); return importProject(readTopology(values), counter()); } /** describeFileEntries' shape (tools/swift-oracle). */ function describeFileEntries(entries: VirtualFileEntry[]): unknown[] { return entries.map(({ path, content }) => { switch (content.kind) { case 'directory': return { path, kind: 'directory' }; case 'text': return { path, kind: 'text', text: content.text }; case 'binary': case 'image': return { path, kind: content.kind, bytes: content.data.length, mediaType: content.mediaType ?? null, sha256: createHash('sha256').update(content.data).digest('hex'), }; } }); } function fileEntriesOf(fileSystem: VirtualFileSystem | undefined): unknown { return fileSystem === undefined ? null : describeFileEntries(fileSystem.allEntries()); } const DEFAULT_FILES = describeFileEntries(VirtualFileSystem.defaultForDevice().allEntries()); const EMPTY_FILES = describeFileEntries(new VirtualFileSystem().allEntries()); /** Programs Swift's import drops and this one keeps (Java has them installed). */ const PROGRAMS_SWIFT_DROPS = new Set(['commandPrompt', 'fileExplorer', 'imageViewer', 'textEditor', 'echoServer', 'simpleClient']); /** * describeSystem's shape. A firewall as the core has it without one * (inactive, drop, no rules) is left out: Swift keeps a configuration only * when the file sets a field, and the core treats both alike. */ function systemOf(project: ImportedProject, nodeId: Id) { const result: Record = {}; const switchConfiguration = project.switchConfigurations.get(nodeId); if (switchConfiguration !== undefined) { result.switch = { ssid: switchConfiguration.ssid === defaultSwitchConfiguration(nodeId).ssid ? '' : switchConfiguration.ssid, retentionTimeMilliseconds: switchConfiguration.retentionTimeMilliseconds, }; } const wireless = project.hostWirelessConfigurations.get(nodeId); if (wireless !== undefined) result.hostWireless = wireless; const routes = project.manualRoutesByNodeId.get(nodeId); if (routes !== undefined) { result.manualRoutes = routes.map((route) => [route.destinationNetwork, route.subnetMask, route.gateway, route.interfaceIPAddress]); } const rip = project.ripEnabledByNodeId.get(nodeId); if (rip !== undefined) result.ripEnabled = rip; const dhcpClient = project.dhcpClientConfigurations.get(nodeId); if (dhcpClient !== undefined) result.dhcpClient = dhcpClient.isEnabled; const dhcpServer = project.dhcpServerConfigurations.get(nodeId); if (dhcpServer !== undefined) { result.dhcpServer = { ...dhcpServer, staticAssignments: dhcpServer.staticAssignments.map((entry) => [entry.macAddress, entry.ipAddress]), }; } const forwarding = project.portForwardingRowsByNodeId.get(nodeId); if (forwarding !== undefined) { result.portForwarding = forwarding.map((row) => [row.protocolValue, row.publicPortValue, row.lanIPAddress, row.lanPortValue]); } const programs = project.installedPrograms.get(nodeId); const kept = [...(programs ?? [])].filter((program) => !PROGRAMS_SWIFT_DROPS.has(program)).sort(); if (kept.length > 0) result.installedPrograms = kept; const web = project.webServerConfigurations.get(nodeId); if (web !== undefined) { // Swift serves the same files from /www after moving them there. result.webServer = { port: web.port, documentRoot: web.documentRoot === '/webserver' ? '/www' : web.documentRoot, virtualHosts: web.virtualHosts }; } const gnutella = project.gnutellaConfigurations.get(nodeId); if (gnutella !== undefined) result.gnutella = gnutella; const dns = project.dnsServerConfigurations.get(nodeId); if (dns !== undefined) { result.dnsServer = { recursiveResolutionEnabled: dns.recursiveResolutionEnabled, forwardingServerIPAddress: dns.forwardingServerIPAddress ?? null, records: dns.records.map((record) => [record.name, record.type, record.ttlSeconds, record.target]), }; } const emailClient = project.emailClientConfigurations.get(nodeId); // Swift keeps no client without an account. if (emailClient?.account) { const { account } = emailClient; result.emailClient = { pop3Host: account.pop3Host, pop3Port: account.pop3Port, smtpHost: account.smtpHost, smtpPort: account.smtpPort, username: account.username, password: account.password, name: fullName(account), email: account.email, inbox: emailClient.inbox.map(swiftMessage), sent: emailClient.sent.map(swiftMessage), drafts: emailClient.drafts.map(swiftMessage), nextMessageID: emailClient.nextMessageID, }; } const emailServer = project.emailServerConfigurations.get(nodeId); if (emailServer !== undefined) { result.emailServer = { domain: emailServer.domain, pop3Port: emailServer.pop3Port, accounts: emailServer.accounts.map((account) => ({ username: account.username, password: account.password, name: fullName(account), mailbox: account.mailbox.map(swiftMessage), })), nextMessageID: emailServer.nextMessageID, }; } const remoteLink = project.remoteLinkConfigurations.get(nodeId); if (remoteLink !== undefined) result.remoteLink = remoteLink; const firewall = project.firewallConfigurationsByNodeId.get(nodeId); if (firewall !== undefined && !deepEqual(firewall, firewallConfiguration())) { result.firewall = { isActive: firewall.isActive, defaultPolicy: firewall.defaultPolicy === 'accept' ? 1 : 0, dropICMP: firewall.dropIcmp, filterSYNSegmentsOnly: firewall.filterSynSegmentsOnly, filterUDP: firewall.filterUdp, rules: firewall.rules.map((rule) => [ rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask, rule.port, FIREWALL_PROTOCOL[rule.protocol], rule.action === 'accept' ? 1 : 0, ]), }; } return result; } /** Swift keeps one name, first and last joined. */ function fullName(person: { firstName: string; lastName: string }): string { return [person.firstName, person.lastName].filter((part) => part !== '').join(' '); } function swiftMessage(message: EmailMessage) { const address = (entry: EmailAddress) => [entry.name, entry.mailAddress]; return { id: message.id, from: address(message.from), to: message.to.map(address), cc: message.cc.map(address), bcc: message.bcc.map(address), subject: message.subject, body: message.body, deliveryIdentity: null, receivedAtMilliseconds: message.receivedAtMilliseconds ?? null, isNew: message.isNew, isMarkedForDeletion: message.isMarkedForDeletion, isSent: message.isSent, }; } /** The shape describeImportedState (tools/swift-oracle) writes: indices instead of random ids. */ function asSwiftState(project: ImportedProject) { const { nodes, links } = project.graph; const nodeIndex = new Map(nodes.map((node, index) => [node.id, index])); const portIndex = (nodeId: Id, portId: Id) => nodes[nodeIndex.get(nodeId)!]!.ports.findIndex((port) => port.id === portId); return { nodes: nodes.map((node) => ({ kind: node.kind, displayName: node.displayName, hostLabelPolicy: node.hostLabelPolicy, position: [node.position.x, node.position.y], ports: node.ports.map((port) => ({ label: port.label, mac: port.importedMacAddress ?? null, occupied: port.isOccupied })), device: project.deviceConfigurations.get(node.id) ?? null, interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null), system: systemOf(project, node.id), fileSystem: fileEntriesOf(project.virtualFileSystems.get(node.id)), })), links: links.map((link) => [ nodeIndex.get(link.sourceNodeId), portIndex(link.sourceNodeId, link.sourcePortId), nodeIndex.get(link.targetNodeId), portIndex(link.targetNodeId, link.targetPortId), ]), documentation: project.documentationItems.map((item) => ({ id: item.id, kind: item.kind, frame: [item.frame.x, item.frame.y, item.frame.width, item.frame.height], text: item.text, color: [item.color.red, item.color.green, item.color.blue, item.color.alpha], fontName: item.fontName, fontSize: item.fontSize, isBold: item.isBold, order: item.order, })), }; } /** Java's name for a device the file leaves unnamed (German FILIUS), where Swift uses its own. */ const JAVA_DEFAULT_NAMES: Partial> = { networkSwitch: 'Switch / WLAN', gateway: 'Heimrouter', }; /** * Swift's state with the one intended difference applied: a node the file * leaves unnamed has Java's default name, not Swift's ("Switch / WLAN", not * "Switch"). A node the file names like Swift's default would fail here, not * pass unnoticed. */ function withJavaDefaultNames(state: unknown): unknown { const { nodes, ...rest } = state as { nodes: { kind: NodeKind; displayName: string; fileSystem: unknown }[] }; return { ...rest, nodes: nodes.map((node) => { const named = node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined ? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] } : node; // A host the file gives no files: empty, as in Java — Swift fills in // the demo files of a new device. return deepEqual(named.fileSystem, DEFAULT_FILES) ? { ...named, fileSystem: EMPTY_FILES } : named; }), }; } /** * Where the Swift import is wrong and Java (corpus.java.json) is right, per * file and node: the corrected node. */ type SwiftNode = { displayName: string; hostLabelPolicy: string; system: Record; fileSystem: unknown }; const shownByIp = (displayName: string) => (node: SwiftNode) => ({ ...node, displayName, hostLabelPolicy: 'ipAddress' }); /** * Swift numbers a server mailbox's messages as their received time when * the file has none (Java never sets one), and writes the numbers into the * konten.txt it rewrites. */ const unnumberedMailbox = (numbered: string[]) => (node: SwiftNode) => { const server = node.system.emailServer as { accounts: { mailbox: { receivedAtMilliseconds: unknown }[] }[] }; const fileSystem = (node.fileSystem as { path: string; text?: string }[]).map((entry) => entry.path === '/mailserver/konten.txt' ? { ...entry, text: numbered.reduce((text, number) => text.replace(`$$$${number}$`, () => '$$$$'), entry.text!) } : entry, ); return { ...node, fileSystem, system: { ...node.system, emailServer: { ...server, accounts: server.accounts.map((account) => ({ ...account, mailbox: account.mailbox.map((message) => ({ ...message, receivedAtMilliseconds: null })), })), }, }, }; }; /** Swift reads a web server's useVHost as off. */ const servesVirtualHosts = (node: SwiftNode) => ({ ...node, system: { ...node.system, webServer: { ...(node.system.webServer as object), virtualHosts: true } }, }); const SWIFT_ERRORS: Record SwiftNode>> = { 'ipad-fixtures/Public_and_Private_Networks_2_EN.fls': { 2: servesVirtualHosts }, 'java-examples/Public_and_Private_Networks_2_EN.fls': { 2: servesVirtualHosts }, 'java-examples/private_und_public_networks_mit_provider_DE.fls': { 6: servesVirtualHosts }, 'java-examples/man-in-the-middle.fls': { 5: unnumberedMailbox(['1', '2']) }, 'java-examples/multicast_server.fls': { // useIPAsName is ``, the shared result of a // button's isEnabled() — true. Swift's reader cannot follow it: it shows // the label text as a manual name instead of the IP address. 0: shownByIp('Rechner'), // maxTeilnehmerZahl is ``, the shared result of // a button's getIconTextGap() — 4. Swift falls back to its default 3. 1: (node) => ({ ...shownByIp('Notebook')(node), system: { ...node.system, gnutella: { maximumKnownPeers: 4 } } }), 2: shownByIp('Notebook'), }, }; function withSwiftErrorsCorrected(name: string, state: unknown): unknown { const corrections = SWIFT_ERRORS[name] ?? {}; const { nodes, ...rest } = state as { nodes: SwiftNode[] }; return { ...rest, nodes: nodes.map((node, index) => corrections[index]?.(node) ?? node) }; } /** * What Swift's programs do to a file system and ours do not: Swift keeps the * e-mail and Gnutella state as hidden files too (left out here; the model * holds that state), and moves a web server's /webserver to /www (compared * here by reading our /webserver as /www where Swift has one). Everything * else in a file system must match — also the /mail, /mailserver and * /peer2peer directories Swift creates and the konten.txt files it rewrites. */ const SWIFT_STATE_FILES = [ '/mail/.filiuspad-email-client.json', '/mailserver/.filiuspad-email-server.json', '/peer2peer/.filiuspad-gnutella.json', ]; /** * Directories Swift creates for those files: left out of Swift's state when * they hold nothing else and the file had none (ours has none then). */ const SWIFT_STATE_DIRECTORIES = ['/mail', '/mailserver', '/peer2peer']; type FileEntries = { path: string }[] | null; function withoutApplicationDirectories( state: unknown, webRootOf?: (index: number) => boolean, ours?: unknown, ): unknown { const { nodes, ...rest } = state as { nodes: { fileSystem: FileEntries }[] }; const ourNodes = (ours as { nodes: { fileSystem: FileEntries }[] } | undefined)?.nodes; const asWebRoot = (entry: { path: string }) => entry.path === '/webserver' || entry.path.startsWith('/webserver/') ? { ...entry, path: `/www${entry.path.slice('/webserver'.length)}` } : entry; return { ...rest, nodes: nodes.map((node, index) => { if (node.fileSystem === null) return node; const files = node.fileSystem.filter((entry) => !SWIFT_STATE_FILES.includes(entry.path)); const holdsMore = (directory: string) => files.some((entry) => entry.path.startsWith(`${directory}/`)); const ourPaths = new Set((ourNodes?.[index]?.fileSystem ?? []).map((entry) => entry.path)); const outside = (entry: { path: string }) => !(ourNodes !== undefined && SWIFT_STATE_DIRECTORIES.includes(entry.path) && !holdsMore(entry.path) && !ourPaths.has(entry.path)); const entries = files.filter(outside).map((entry) => (webRootOf?.(index) ? asWebRoot(entry) : entry)); return { ...node, fileSystem: entries.sort((lhs, rhs) => (lhs.path < rhs.path ? -1 : lhs.path > rhs.path ? 1 : 0)) }; }), }; } /** Whether Swift moved node `index`'s web files to /www. */ function swiftWebRoots(state: unknown): (index: number) => boolean { const { nodes } = state as { nodes: { fileSystem: FileEntries }[] }; return (index) => nodes[index]?.fileSystem?.some((entry) => entry.path === '/www') ?? false; } const complete = (summary: SwiftSummary) => summary.skipped === 0 && !(summary.warnings ?? []).some((warning) => warning.startsWith('Skipped')); describe('corpus: the imported model as Swift builds it', () => { const files = flsFiles(corpusRoot).filter((file) => complete(swiftView[relative(corpusRoot, file)]!)); it('has files to compare', () => { expect(files.length).toBe(13); }); for (const file of files) { const name = relative(corpusRoot, file); it(name, () => { const project = importFile(file); const expected = withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state)); const ours = withoutApplicationDirectories(asSwiftState(project), swiftWebRoots(expected)); expect(ours).toEqual(withoutApplicationDirectories(expected, undefined, ours)); expect(project.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []); }); } }); describe('corpus: every file imports completely', () => { const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record< string, { nodes: unknown[]; cables: unknown[]; documentation: unknown[] } >; // Also the files where Swift loses devices: as many devices and cables as // Java has, and nothing left out. for (const file of flsFiles(corpusRoot)) { const name = relative(corpusRoot, file); it(name, () => { const project = importFile(file); expect(project.graph.nodes).toHaveLength(javaView[name]!.nodes.length); expect(project.graph.links).toHaveLength(javaView[name]!.cables.length); expect(project.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []); }); } }); /** The core started on an imported project (the editor will derive this snapshot from its state). */ function startedEngine(project: ImportedProject): NetworkRuntimeEngine { const engine = new NetworkRuntimeEngine(14); engine.handle({ type: 'start', snapshot: topologySnapshot({ nodes: project.graph.nodes.map((node) => ({ id: node.id, kind: node.kind, ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.importedMacAddress)), })), links: project.graph.links, deviceConfigurations: project.deviceConfigurations, interfaceConfigurations: project.interfaceConfigurations, manualRoutesByNodeId: project.manualRoutesByNodeId, ripEnabledByNodeId: project.ripEnabledByNodeId, firewallConfigurationsByNodeId: project.firewallConfigurationsByNodeId, switchConfigurationsByNodeId: project.switchConfigurations, }), seed: 14, initialTimeMilliseconds: 0, }); return engine; } describe('an imported file runs in the simulation core', () => { it('pings across the router from the notebook with a gateway, not from the one without', () => { const project = importFile(join(corpusRoot, 'java-examples/zwei_rechnernetze_komplett.fls')); const byName = (displayName: string) => project.graph.nodes.find((node) => node.displayName === displayName)!.id; const engine = startedEngine(project); expect(engine.sendIcmpEcho(byName('Notebook 1.10'), '141.99.2.10').type).toBe('delivered'); expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.2.10')).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' }); expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.1.10').type).toBe('delivered'); }); it('pings over wireless cables, a cable like any other at run time (Java Kabel.wireless)', () => { const project = importFile(join(corpusRoot, 'java-examples/multicast_server.fls')); const byIp = (ip: string) => project.graph.nodes.find((node) => project.deviceConfigurations.get(node.id)?.ipAddress === ip)!.id; const notebook = byIp('192.168.0.10'); expect(project.hostWirelessConfigurations.get(notebook)?.isEnabled).toBe(true); expect(project.graph.links).toHaveLength(3); expect(startedEngine(project).sendIcmpEcho(notebook, '192.168.0.20').type).toBe('delivered'); }); it('pings across three routers on manual routes and the routers\' default gateways', () => { // Router 1 (141.99.1/2) sends everything else to 2.2; router 2 has manual // routes to 1.0 and 5.0; router 3 (141.99.4/5) sends everything else to // 4.1. The way back needs router 3's default gateway — which Swift drops. const project = importFile(join(corpusRoot, 'java-examples/internetwork_komplett.fls')); const notebook = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!.id; const routers = project.graph.nodes.filter((node) => node.kind === 'router').map((node) => node.id); expect(routers.map((router) => project.deviceConfigurations.get(router)?.defaultGateway)).toEqual([ '141.99.2.2', '141.99.4.2', '141.99.4.1', ]); expect(startedEngine(project).sendIcmpEcho(notebook, '141.99.5.10').type).toBe('delivered'); const withoutRouterGateways = { ...project, deviceConfigurations: new Map(project.deviceConfigurations) }; for (const router of routers) withoutRouterGateways.deviceConfigurations.delete(router); expect(startedEngine(withoutRouterGateways).sendIcmpEcho(notebook, '141.99.5.10').type).not.toBe('delivered'); }); }); // ── Rules the corpus does not exercise ───────────────────────────────────── function flsNode(javaClass: FlsNode['javaClass'], fields: Partial = {}): FlsNode { return { javaClass, name: javaClass, x: 0, y: 0, useIpAsName: false, useMacAsName: false, cloud: false, interfaces: [], portCount: 0, system: {}, source: { kind: 'object', className: javaClass, id: undefined, args: [], staticField: undefined, factoryMethod: undefined, properties: new Map(), propertyKinds: new Map(), propertiesFromGetters: new Set(), argKinds: [], calls: [], presetElements: new Map(), presetEntries: new Map(), fromGetter: false }, ...fields, }; } function topology(fields: Partial): FlsTopology { return { versionLine: null, nodes: [], links: [], documentation: [], warnings: [], ...fields }; } const nic = (ip: string | null, mask: string | null = '255.255.255.0') => ({ ip, mask, mac: undefined, gateway: '', dns: '', wireless: false }); describe('importProject rules', () => { it('labels a gateway wan0, lan0, gw2 and gives a rejected address the default', () => { const project = importProject( topology({ nodes: [flsNode('Gateway', { interfaces: [nic(null, null), nic('10.0.0.1'), nic('10.1.0.1')] })] }), counter(), ); const gateway = project.graph.nodes[0]!; expect(gateway.ports.map((port) => port.label)).toEqual(['wan0', 'lan0', 'gw2']); expect(gateway.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(gateway.id, port.id)))).toEqual([ { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' }, { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }, { ipAddress: '10.1.0.1', subnetMask: '255.255.255.0' }, ]); expect(project.deviceConfigurations.get(gateway.id)).toEqual({ ipAddress: '42.0.0.10', subnetMask: '255.0.0.0', defaultGateway: '', dnsServer: '', }); }); it('keeps an empty name and shows the IP address only on hosts', () => { const project = importProject( topology({ nodes: [ flsNode('Vermittlungsrechner', { name: '', useIpAsName: true }), flsNode('Notebook', { useIpAsName: true, useMacAsName: true }), ], }), counter(), ); expect(project.graph.nodes.map((node) => [node.displayName, node.hostLabelPolicy])).toEqual([ ['', 'manual'], ['Notebook', 'ipAndMAC'], ]); }); it("takes a switch's and a router's port count from Java, and gives a rejected router address the default", () => { const project = importProject( topology({ nodes: [flsNode('Switch', { portCount: 3 }), flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.1'), nic(null)] })], }), counter(), ); const [networkSwitch, router] = project.graph.nodes; expect(networkSwitch!.ports.map((port) => port.label)).toEqual(['sw1', 'sw2', 'sw3']); expect(router!.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(router!.id, port.id)))).toEqual([ { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }, { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }, ]); }); it('reads bold from the font style and alpha from the color', () => { const project = importProject( topology({ documentation: [ { type: 2, text: 'b', x: 0, y: 0, width: 50, height: 30, color: [0, 0, 0, 51], font: { name: 'Dialog', size: 18, style: 1 } }, ], }), counter(), ); expect(project.documentationItems[0]).toMatchObject({ isBold: true, fontSize: 18, color: { alpha: 0.2 } }); }); it("maps a router's firewall, RIP and routes, and says what it cannot map", () => { const project = importProject( topology({ nodes: [ flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.1')], system: { ripEnabled: true, manualRoutes: [ ['10.1.0.0', '255.255.0.0', '10.0.0.2', '10.0.0.1'], ['10.2.0.0', '255.255.0.0', null, '10.0.0.1'], ], firewall: { activated: true, defaultPolicy: 1, dropICMP: true, filterSYNSegmentsOnly: false, filterUdp: false, rules: [ { srcIP: '10.0.0.0', srcMask: '255.0.0.0', destIP: null, destMask: '', port: 80, protocol: -1, action: 1 }, { srcIP: '', srcMask: '', destIP: '', destMask: '', port: -1, protocol: 99, action: 0 }, ], }, }, }), ], }), counter(), ); const router = project.graph.nodes[0]!.id; expect(project.ripEnabledByNodeId.get(router)).toBe(true); expect(project.manualRoutesByNodeId.get(router)).toEqual([ { destinationNetwork: '10.1.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }, ]); expect(project.firewallConfigurationsByNodeId.get(router)).toEqual({ isActive: true, defaultPolicy: 'accept', dropIcmp: true, filterSynSegmentsOnly: false, filterUdp: false, rules: [ { sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', destinationIPAddress: '', destinationSubnetMask: '', port: 80, protocol: 'all', action: 'accept', }, { sourceIPAddress: '', sourceSubnetMask: '', destinationIPAddress: '', destinationSubnetMask: '', port: -1, protocol: 'tcp', action: 'drop', }, ], }); expect(project.warnings).toEqual([ "node 'Vermittlungsrechner': a manual route without four entries, left out", "node 'Vermittlungsrechner': firewall protocol 99 unknown, used TCP", ]); }); it("gives a switch without SSID one from its id, and keeps a host's wireless card and SSID", () => { const wirelessNic = (ip: string) => ({ ...nic(ip), wireless: true }); const project = importProject( topology({ nodes: [ flsNode('Switch', { portCount: 24, system: { ssid: undefined, retentionTime: 2000 } }), flsNode('Switch', { portCount: 24, system: { ssid: ' MyWiFi ', retentionTime: 300_000 } }), flsNode('Notebook', { interfaces: [wirelessNic('10.0.0.1')], system: { ssid: 'MyWiFi', manualRoutes: [] } }), flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { ssid: ' ', manualRoutes: [] } }), flsNode('Notebook', { interfaces: [nic('10.0.0.3')], system: { ssid: 'MyWiFi', manualRoutes: [] } }), flsNode('Notebook', { interfaces: [wirelessNic('10.0.0.4')], system: { ssid: null, manualRoutes: [] } }), ], }), counter(), ); const [first, second, notebook, pc, wired, withoutNetwork] = project.graph.nodes.map((node) => node.id); expect(project.switchConfigurations.get(first!)).toEqual({ ssid: defaultSwitchConfiguration(first!).ssid, retentionTimeMilliseconds: 2000 }); expect(project.switchConfigurations.get(second!)).toEqual({ ssid: 'MyWiFi', retentionTimeMilliseconds: 300_000 }); expect(project.hostWirelessConfigurations.get(notebook!)).toEqual({ isEnabled: true, ssid: 'MyWiFi' }); expect(project.hostWirelessConfigurations.has(pc!)).toBe(false); // Java joins only with a wireless card: an SSID alone keeps the cable. expect(project.hostWirelessConfigurations.get(wired!)).toEqual({ isEnabled: false, ssid: 'MyWiFi' }); expect(project.hostWirelessConfigurations.get(withoutNetwork!)).toEqual({ isEnabled: true, ssid: '' }); }); it('runs every DNS server, switched on or off, and says which is on (projectSnapshot)', () => { const dnsServer = (isActive: boolean): FlsApplication => ({ key: 'dns', javaClass: 'filius.software.dns.DNSServer', source: flsNode('Rechner').source, isActive }); const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { applications: [dnsServer(true)] } }), flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { applications: [dnsServer(false)] } }), ], }), counter(), ); const [on, off] = project.graph.nodes.map((node) => node.id); expect([...project.dnsServerConfigurations.keys()]).toEqual([on, off]); expect([...projectSnapshot(project).dnsServerConfigurationsByNodeId].map(([id, server]) => [id, server.isActive])).toEqual([ [on, true], [off, false], ]); }); it('runs every echo server, switched on or off, and every generic client (projectSnapshot)', () => { const source = flsNode('Rechner').source; const echoServer = (isActive: boolean, port: number): FlsApplication => ({ key: 'echo', javaClass: 'filius.software.clientserver.ServerBaustein', source, isActive, port }); const client: FlsApplication = { key: 'client', javaClass: 'filius.software.clientserver.ClientBaustein', source, targetAddress: '10.0.0.1', targetPort: 7 }; const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { applications: [echoServer(true, 55555)] } }), flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { applications: [echoServer(false, 7), client] } }), ], }), counter(), ); const [on, off] = project.graph.nodes.map((node) => node.id); const snapshot = projectSnapshot(project); expect([...snapshot.echoServersByNodeId]).toEqual([ [on, { port: 55555, isActive: true }], [off, { port: 7, isActive: false }], ]); expect([...snapshot.simpleClientsByNodeId]).toEqual([[off, { targetAddress: '10.0.0.1', targetPort: 7 }]]); }); it('runs every web server, switched on or off, every web browser, on the nodes\' files (projectSnapshot)', () => { const source = flsNode('Rechner').source; const webServer = (isActive: boolean): FlsApplication => ({ key: 'web', javaClass: 'filius.software.www.WebServer', source, isActive, port: 80 }); const browser: FlsApplication = { key: 'browser', javaClass: 'filius.software.www.WebBrowser', source }; const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { applications: [webServer(true)] } }), flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { applications: [webServer(false), browser] } }), ], }), counter(), ); const [on, off] = project.graph.nodes.map((node) => node.id); const snapshot = projectSnapshot(project); expect([...snapshot.webServersByNodeId]).toEqual([ [on, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: true }], [off, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }], ]); expect([...snapshot.webBrowserNodeIds]).toEqual([off]); expect(snapshot.fileSystemsByNodeId).toBe(project.virtualFileSystems); }); it("keeps a router's firewall set on its page konfig in the project saved after the run (projectWithSimulationChanges)", () => { const project = openProjectArchive(new Uint8Array(readFileSync(join(corpusRoot, 'java-examples/internetwork_komplett.fls'))), counter()); const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!.id; const router = project.graph.nodes.find((each) => each.kind === 'router' && project.deviceConfigurations.get(each.id)?.ipAddress === '141.99.1.1')!.id; const notebook = node('Notebook 1.10'); const engine = new NetworkRuntimeEngine(14); engine.handle({ type: 'start', snapshot: { ...projectSnapshot(project), webBrowserNodeIds: new Set([notebook]) }, seed: 14, initialTimeMilliseconds: 0 }); const form = 'srcIP=&srcMask=&destIP=&destMask=&protocol=UDP&port=53&action=accept'; // Added first: once switched on, the firewall drops the browser too (by default). engine.webBrowser(notebook)!.open('141.99.1.1/konfig', `onlySYN=1&defaultPolicy=drop&${form}&addrule=hinzuf%C3%BCgen`); engine.handle({ type: 'advance', toMilliseconds: 5_000 }); engine.webBrowser(notebook)!.open('141.99.1.1/konfig', `firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=drop&${form}`); engine.handle({ type: 'advance', toMilliseconds: 10_000 }); const expected = firewallConfiguration({ isActive: true, rules: [firewallRule({ port: 53, protocol: 'udp', action: 'accept' })] }); expect(engine.firewallConfiguration(router)).toEqual(expected); // The project itself is as it was opened, until the run's changes go back into it. expect(project.firewallConfigurationsByNodeId.get(router)).toEqual(firewallConfiguration()); const saved = saveProjectArchive(projectWithSimulationChanges(project, engine.state.topologySnapshot)).archive; const reopened = openProjectArchive(saved, counter()); const reopenedRouter = reopened.graph.nodes.find((each) => each.kind === 'router' && reopened.deviceConfigurations.get(each.id)?.ipAddress === '141.99.1.1')!.id; expect(reopened.firewallConfigurationsByNodeId.get(reopenedRouter)).toEqual(expected); }); it("keeps a mail the e-mail server took during the run in the project saved after it (projectWithSimulationChanges)", () => { const project = openProjectArchive(new Uint8Array(readFileSync(join(corpusRoot, 'java-examples/email_komplett.fls'))), counter()); const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!.id; const server = node('Mail-Server (filius.de)'); const sender = node('Rechner 1'); const engine = new NetworkRuntimeEngine(14); const snapshot = projectSnapshot(project); expect(snapshot.emailServersByNodeId.get(server)).toEqual({ domain: 'filius.de', accounts: project.emailServerConfigurations.get(server)!.accounts, isActive: true }); const switchedOff = { ...project, activePrograms: new Map([...project.activePrograms].map(([id, programs]) => [id, new Set([...programs].filter((each) => each !== 'emailServer'))])) }; expect(projectSnapshot(switchedOff).emailServersByNodeId.get(server)?.isActive).toBe(false); engine.handle({ type: 'start', snapshot: { ...snapshot, simpleClientsByNodeId: new Map([[sender, { targetAddress: '141.99.50.232', targetPort: 25 }]]) }, seed: 14, initialTimeMilliseconds: 0, }); const client = engine.simpleClient(sender)!; client.connect('141.99.50.232', 25); const commands = ['HELO 141.99.50.181', 'MAIL FROM:', 'RCPT TO:', 'DATA', 'From: \nTo: \nSubject: Hallo\n\nGespeichert?\n.\n', 'QUIT']; for (const [index, command] of commands.entries()) { engine.handle({ type: 'advance', toMilliseconds: 5_000 + index * 2_000 }); client.send(command); } engine.handle({ type: 'advance', toMilliseconds: 20_000 }); // The project itself is as it was opened, until the run's changes go back into it. expect(project.emailServerConfigurations.get(server)!.accounts[1]!.mailbox).toEqual([]); const saved = saveProjectArchive(projectWithSimulationChanges(project, engine.state.topologySnapshot)).archive; const reopened = openProjectArchive(saved, counter()); const reopenedServer = reopened.graph.nodes.find((each) => each.displayName === 'Mail-Server (filius.de)')!.id; const [first, second] = reopened.emailServerConfigurations.get(reopenedServer)!.accounts; expect(first!.mailbox).toEqual([]); expect(second!.mailbox).toMatchObject([{ from: { name: null, mailAddress: 'rechner1@filius.de' }, to: [{ name: null, mailAddress: 'rechner2@filius.de' }], subject: 'Hallo', body: 'Gespeichert?\n' }]); }); it('keeps what the e-mail programs sent and fetched in the project saved after the run (projectWithSimulationChanges)', () => { const project = openProjectArchive(new Uint8Array(readFileSync(join(corpusRoot, 'java-examples/email_komplett.fls'))), counter()); const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!.id; const [sender, receiver] = [node('Rechner 1'), node('Rechner 3')]; const withoutProgram = { ...project, installedPrograms: new Map([...project.installedPrograms].map(([id, programs]) => [id, id === sender ? new Set() : programs])) }; expect(projectSnapshot(withoutProgram).emailClientsByNodeId.has(sender)).toBe(false); const engine = new NetworkRuntimeEngine(14); engine.handle({ type: 'start', snapshot: projectSnapshot(project), seed: 14, initialTimeMilliseconds: 0 }); expect(engine.emailClient(sender)!.send({ to: 'rechner3@senior.de', cc: '', bcc: '', subject: 'Gruß', text: 'Über den MX' })).toBeUndefined(); engine.handle({ type: 'advance', toMilliseconds: 20_000 }); expect(engine.emailClient(receiver)!.fetch()).toBe(true); engine.handle({ type: 'advance', toMilliseconds: 30_000 }); const reopened = openProjectArchive(saveProjectArchive(projectWithSimulationChanges(project, engine.state.topologySnapshot)).archive, counter()); const client = (name: string) => reopened.emailClientConfigurations.get(reopened.graph.nodes.find((each) => each.displayName === name)!.id)!; const mail = { from: { name: 'Rechner 1', mailAddress: 'rechner1@filius.de' }, to: [{ name: null, mailAddress: 'rechner3@senior.de' }], subject: 'Gruß' }; expect(client('Rechner 1').sent).toMatchObject([{ ...mail, body: 'Über den MX', isSent: true }]); expect(client('Rechner 3').inbox).toMatchObject([{ ...mail, body: 'Über den MX\n', isSent: false }]); expect(client('Rechner 3').account).toEqual(project.emailClientConfigurations.get(receiver)!.account); }); it("keeps what the command line did to a node's files in the project saved after the run", () => { const name = 'java-examples/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'; const project = openProjectArchive(new Uint8Array(readFileSync(join(corpusRoot, name))), counter()); const notebook = project.graph.nodes.find((each) => each.displayName === 'Notebook' && project.deviceConfigurations.get(each.id)?.ipAddress === '10.10.20.11')!.id; const snapshot = projectSnapshot(project); expect(snapshot.terminalNodeIds.has(notebook)).toBe(true); const engine = new NetworkRuntimeEngine(14); engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 }); for (const line of ['mkdir Projekt', 'cd Projekt', 'touch plan.txt']) engine.terminal(notebook)!.enter(line); engine.handle({ type: 'advance', toMilliseconds: 1_000 }); const reopened = openProjectArchive(saveProjectArchive(projectWithSimulationChanges(project, engine.state.topologySnapshot)).archive, counter()); const reopenedNotebook = reopened.graph.nodes.find((each) => each.displayName === 'Notebook' && reopened.deviceConfigurations.get(each.id)?.ipAddress === '10.10.20.11')!.id; expect(reopened.virtualFileSystems.get(reopenedNotebook)!.entry('/Projekt/plan.txt').content).toEqual({ kind: 'text', text: '' }); }); it("keeps a node's IP forwarding where it differs from its kind's: a host that forwards, a router that does not", () => { const system = (ipForwardingEnabled: boolean) => ({ manualRoutes: [], ipForwardingEnabled }); const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: system(true) }), flsNode('Notebook', { interfaces: [nic('10.0.0.2')], system: system(false) }), flsNode('Vermittlungsrechner', { portCount: 2, interfaces: [nic('10.0.0.3'), nic('10.1.0.3')], system: system(true) }), flsNode('Vermittlungsrechner', { portCount: 2, interfaces: [nic('10.0.0.4'), nic('10.1.0.4')], system: system(false) }), ], }), counter(), ); const [pc, notebook, router, quietRouter] = project.graph.nodes.map((node) => node.id); expect([...project.ipForwardingByNodeId]).toEqual([ [pc, true], [quietRouter, false], ]); expect([notebook, router].some((id) => project.ipForwardingByNodeId.has(id!))).toBe(false); }); it('takes DHCP settings for hosts and home routers, trimmed, and only when they are not the defaults', () => { const defaults = { isActive: false, lowerBound: '0.0.0.0', upperBound: '0.0.0.0', gateway: '0.0.0.0', dnsServer: '0.0.0.0', useOwnSettings: false, staticAssignedAddresses: [] }; const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { dhcpClient: false, dhcpServer: { ...defaults, isActive: true, lowerBound: ' 10.0.0.100 ', upperBound: '10.0.0.150', staticAssignedAddresses: [{ mac: '0A:00:00:00:00:01', ip: '10.0.0.101' }], }, }, }), flsNode('Notebook', { interfaces: [nic('10.0.0.2')], system: { dhcpClient: true, dhcpServer: defaults } }), flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.3')], system: { dhcpClient: true, dhcpServer: { ...defaults, isActive: true } }, }), ], }), counter(), ); const [pc, notebook, router] = project.graph.nodes.map((node) => node.id); expect(project.dhcpServerConfigurations.get(pc!)).toMatchObject({ isActive: true, lowerBoundIPAddress: '10.0.0.100', upperBoundIPAddress: '10.0.0.150', staticAssignments: [{ macAddress: '0A:00:00:00:00:01', ipAddress: '10.0.0.101' }], }); expect([...project.dhcpServerConfigurations.keys()]).toEqual([pc]); expect([...project.dhcpClientConfigurations]).toEqual([[notebook, { isEnabled: true }]]); expect(project.warnings).toEqual([ "node 'Vermittlungsrechner': DHCP client on a router, ignored", "node 'Vermittlungsrechner': DHCP server settings on a router, ignored", ]); expect(router).toBeDefined(); }); it('takes port forwarding rows of a home router and pairs modems by port', () => { const project = importProject( topology({ nodes: [ flsNode('Gateway', { interfaces: [nic('42.0.0.10'), nic('192.168.0.1')], system: { staticNAT: [[' TCP', '80', '192.168.0.10', '8080 '], ['UDP', null, '192.168.0.11', '53']] }, }), flsNode('Modem', { portCount: 1, system: { modem: { mode: 1, port: 4242, ipAddress: 'localhost' } } }), flsNode('Modem', { portCount: 1, system: { modem: { mode: 7, port: 70_000, ipAddress: 'localhost' } } }), ], }), counter(), ); const [gateway, server, broken] = project.graph.nodes.map((node) => node.id); expect(project.portForwardingRowsByNodeId.get(gateway!)).toEqual([ { protocolValue: 'TCP', publicPortValue: '80', lanIPAddress: '192.168.0.10', lanPortValue: '8080' }, ]); expect(project.remoteLinkConfigurations.get(server!)).toEqual({ pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }); expect(project.remoteLinkConfigurations.get(broken!)?.pairIdentifier).toBe('java-modem-port-12345'); expect(project.warnings).toEqual([ "node 'Gateway': a port forwarding row without four entries, left out", "node 'Modem': modem port 70000 is invalid, used 12345", "node 'Modem': modem mode 7 unknown, treated as client", ]); }); it("maps a host's files by type and extension, as Swift does, and says what it had to guess", () => { const file = (name: string | null, type: string | null, content: string | null): FlsFileTree => ({ kind: 'file', name, type, content, children: [], }); const directory = (name: string | null, children: FlsFileTree[]): FlsFileTree => ({ kind: 'directory', name, children }); const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { fileSystem: directory('root', [ directory(' docs ', [ file('a.txt', 'text/txt', 'hallo'), file('pic.JPG', 'txt', 'AAEC'), file('bad.png', 'png', 'not base64!'), file('blob.bin', 'Bin', 'AAEC'), file('odd.xyz', 'xyz', 'plain words'), file('empty.dat', 'dat', '!!!'), file('icon', 'image', 'AAEC'), file(' ', 'txt', 'x'), file('A.TXT', 'txt', 'collides'), ]), directory(null, [file('lost.txt', 'txt', 'x')]), directory('docs', []), ]), }, }), ], }), counter(), ); const files = project.virtualFileSystems.get(project.graph.nodes[0]!.id)!; expect(files.allEntries().map(({ path, content }) => [path, content.kind, 'mediaType' in content ? content.mediaType : undefined])).toEqual([ ['/', 'directory', undefined], ['/docs', 'directory', undefined], ['/docs/a.txt', 'text', undefined], ['/docs/bad.png', 'binary', 'application/png'], ['/docs/blob.bin', 'binary', 'application/bin'], ['/docs/empty.dat', 'text', undefined], ['/docs/icon', 'image', 'image/png'], ['/docs/odd.xyz', 'text', undefined], ['/docs/pic.JPG', 'image', 'image/txt'], ]); expect(project.warnings).toEqual([ "Java file '/docs/bad.png' declared image type 'png' but was not valid Base64; imported as UTF-8 binary data.", "Java file '/docs/blob.bin' used unknown type 'bin'; preserved as binary data.", "Java file '/docs/odd.xyz' used unknown type 'xyz'; preserved as UTF-8 text.", "Java file '/docs/empty.dat' used unknown type 'dat'; preserved as UTF-8 text.", "Ignored Java filesystem file with missing or unsafe name on 'Rechner'.", "Ignored Java filesystem file '/docs/A.TXT' on 'Rechner': caseInsensitiveSiblingCollision(/docs/a.txt, /docs/A.TXT)", "Ignored Java filesystem directory with missing or unsafe name on 'Rechner'.", "Ignored Java filesystem directory '/docs' on 'Rechner': itemAlreadyExists(/docs)", ]); }); it('leaves out a file over the per-file quota, and rejects an import over the project quota', () => { const tooBig = 'A'.repeat(8 * 1024 * 1024 + 1); const host = (files: FlsFileTree[]) => flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { fileSystem: { kind: 'directory', name: 'root', children: files } } }); const single = importProject(topology({ nodes: [host([{ kind: 'file', name: 'big.txt', type: 'txt', content: tooBig, children: [] }])] }), counter()); expect(single.virtualFileSystems.get(single.graph.nodes[0]!.id)!.contains('/big.txt')).toBe(false); expect(single.warnings).toEqual([ "Ignored Java filesystem file '/big.txt' on 'Rechner': fileSizeQuotaExceeded(/big.txt, 8388609, 8388608)", ]); // Three hosts at their 32 MiB limit exceed the 64 MiB project limit: // the import fails as a whole (one 8 MiB string, shared). const limit = 'A'.repeat(8 * 1024 * 1024); const fullHost = () => host([0, 1, 2, 3].map((index): FlsFileTree => ({ kind: 'file', name: `f${index}.txt`, type: 'txt', content: limit, children: [] }))); expect(() => importProject(topology({ nodes: [fullHost(), fullHost(), fullHost()] }), counter())).toThrow( 'totalSizeQuotaExceeded(100663296, 67108864)', ); // Three devices at their 32 MiB limit exceed the 64 MiB project limit. const full = new Uint8Array(8 * 1024 * 1024); const devices = [0, 1, 2].map(() => { const fileSystem = new VirtualFileSystem(); for (let index = 0; index < 4; index++) fileSystem.writeBinaryFile(`/f${index}`, full); return fileSystem; }); expect(() => VirtualFileSystem.validateProjectQuotas(devices)).toThrow('totalSizeQuotaExceeded(100663296, 67108864)'); }); it("keeps every installed program with its settings, and forwards DNS only to another server", () => { const source = flsNode('Rechner').source; const application = (javaClass: FlsApplication['javaClass'], fields: Partial = {}): FlsApplication => ({ key: javaClass, javaClass, source, ...fields, }); const host = (dns: string, recursive: boolean) => flsNode('Rechner', { interfaces: [{ ip: '10.0.0.1', mask: '255.255.255.0', mac: undefined, gateway: '', dns, wireless: false }], system: { applications: [application('filius.software.dns.DNSServer', { isActive: true, port: 53, recursiveResolutionEnabled: recursive })] }, }); const project = importProject( topology({ nodes: [ flsNode('Notebook', { interfaces: [nic('10.0.0.2')], system: { applications: [ application('filius.software.lokal.Terminal'), application('filius.software.www.WebServer', { isActive: true, port: 8080, useVirtualHosts: false }), application('filius.software.clientserver.ServerBaustein', { isActive: false, port: 7 }), application('filius.software.clientserver.ClientBaustein', { targetAddress: ' 10.0.0.9 ', targetPort: 7 }), application('filius.software.firewall.Firewall', { firewall: { activated: true, defaultPolicy: 1, dropICMP: false, filterSYNSegmentsOnly: true, filterUdp: true, rules: [] }, }), ], }, }), host('8.8.8.8', true), host('10.0.0.1', true), host('8.8.8.8', false), ], }), counter(), ); const [notebook, forwarding, self, iterative] = project.graph.nodes.map((node) => node.id); expect([...project.installedPrograms.get(notebook!)!].sort()).toEqual(['commandPrompt', 'echoServer', 'firewall', 'simpleClient', 'webServer']); expect([...project.activePrograms.get(notebook!)!]).toEqual(['webServer']); expect(project.webServerConfigurations.get(notebook!)).toEqual({ port: 8080, documentRoot: '/webserver', virtualHosts: false }); expect(project.echoServerConfigurations.get(notebook!)).toEqual({ port: 7 }); expect(project.simpleClientConfigurations.get(notebook!)).toEqual({ targetAddress: '10.0.0.9', targetPort: 7 }); expect(project.firewallConfigurationsByNodeId.get(notebook!)).toMatchObject({ isActive: true, defaultPolicy: 'accept' }); expect([forwarding, self, iterative].map((id) => project.dnsServerConfigurations.get(id!))).toEqual([ { recursiveResolutionEnabled: true, forwardingServerIPAddress: '8.8.8.8', records: [] }, { recursiveResolutionEnabled: true, records: [] }, { recursiveResolutionEnabled: false, records: [] }, ]); }); describe('e-mail', () => { const source = flsNode('Rechner').source; const account = (username: string, fields: Partial = {}): JavaEmailAccount => ({ username, password: `${username}-pw`, lastName: 'Lang', firstName: 'Anna Lena', emailAddress: `${username}@filius.de`, pop3Server: '10.0.0.1', pop3Port: '110', smtpServer: '10.0.0.1', smtpPort: '25', messages: [], ...fields, }); const email = (subject: string, fields: Partial = {}): JavaEmail => ({ sender: { name: 'Alice', mailAddress: 'alice@senior.de' }, to: ['Bob '], cc: [], bcc: [], subject, text: 'Hi', dateReceived: '', isNew: true, isDeleted: false, isSent: false, ...fields, }); const host = (application: Partial) => flsNode('Rechner', { name: 'Host', interfaces: [nic('10.0.0.2')], system: { applications: [{ key: 'mail', javaClass: 'filius.software.email.EmailAnwendung', source, ...application }] }, }); const server = (application: Partial) => flsNode('Rechner', { name: 'Server', interfaces: [nic('10.0.0.1')], system: { applications: [{ key: 'mail', javaClass: 'filius.software.email.EmailServer', source, mailDomain: 'filius.de', beanAccounts: [], fileAccounts: null, ...application }], }, }); const imported = (node: FlsNode) => { const project = importProject(topology({ nodes: [node] }), counter()); const id = project.graph.nodes[0]!.id; return { project, client: project.emailClientConfigurations.get(id), server: project.emailServerConfigurations.get(id) }; }; it("takes a server's accounts from /mailserver/konten.txt, and from the bean list without the file", () => { expect(imported(server({ beanAccounts: [account('bean')], fileAccounts: [account('file')] })).server!.accounts.map((a) => a.username)).toEqual(['file']); expect(imported(server({ beanAccounts: [account('bean')], fileAccounts: null })).server!.accounts.map((a) => a.username)).toEqual(['bean']); expect(imported(server({ beanAccounts: [account('bean')], fileAccounts: [] })).server!.accounts).toEqual([]); }); it("numbers a server's messages across its accounts and keeps names apart", () => { const { server: configuration } = imported( server({ fileAccounts: [account('a', { messages: [email('1'), email('2')] }), account('b', { messages: [email('3')] })] }), ); expect(configuration!.accounts.map((a) => a.mailbox.map((m) => [m.id, m.subject]))).toEqual([[[1, '1'], [2, '2']], [[3, '3']]]); expect(configuration!.accounts[0]).toMatchObject({ firstName: 'Anna Lena', lastName: 'Lang', password: 'a-pw' }); expect(configuration!.nextMessageID).toBe(4); }); it("takes a client's account from /konten.txt, else from an older file's kontoListe, else none", () => { expect(imported(host({ fileAccount: account('file'), legacyAccounts: [account('legacy')] })).client!.account?.username).toBe('file'); expect(imported(host({ fileAccount: null, legacyAccounts: [account('legacy'), account('second')] })).client!.account?.username).toBe('legacy'); expect(imported(host({ fileAccount: null, legacyAccounts: [] })).client!.account).toBeNull(); }); it('uses the default for a port that is no port, and says so', () => { const { project, client } = imported(host({ fileAccount: account('u', { pop3Port: 'x', smtpPort: '70000' }) })); expect(client!.account).toMatchObject({ pop3Port: 110, smtpPort: 25 }); expect(project.warnings).toEqual([ "node 'Host': e-mail client: POP3 port 'x' is not a port, used 110", "node 'Host': e-mail client: SMTP port '70000' is not a port, used 25", ]); }); it('numbers the folders in order, marks the sent folder sent, and reads string recipients as addresses', () => { const { project, client } = imported( host({ fileAccount: account('u'), received: [email('in', { isSent: true, dateReceived: '1700000000000' })], sent: [email('out', { to: [null, { name: null, mailAddress: 'x@y' }] })], drafts: [email('draft', { isSent: true, subject: null, text: null })], }), ); expect(client!.inbox[0]).toMatchObject({ id: 1, isSent: true, receivedAtMilliseconds: 1_700_000_000_000, to: [{ name: 'Bob', mailAddress: 'bob@filius.de' }] }); expect(client!.sent[0]).toMatchObject({ id: 2, isSent: true, to: [{ name: null, mailAddress: 'x@y' }] }); expect(client!.sent[0]).not.toHaveProperty('receivedAtMilliseconds'); expect(client!.drafts[0]).toMatchObject({ id: 3, isSent: false, subject: '', body: '' }); expect(client!.nextMessageID).toBe(4); expect(project.warnings).toEqual(["node 'Host': an e-mail recipient is null, left out"]); }); it("keeps a message's flags and a date only when it is a number, and the address book", () => { const { client } = imported( host({ fileAccount: account('u'), received: [email('read', { isNew: false, isDeleted: true, dateReceived: 'Mon, 1 Jan' })], addressBook: [ { name: 'Lang', firstName: null, street: 'Weg', houseNumber: 3, postalCode: 20095, city: 'Hamburg', email: 'a@b', phone: null }, ], }), ); expect(client!.inbox[0]).toMatchObject({ isNew: false, isMarkedForDeletion: true }); expect(client!.inbox[0]).not.toHaveProperty('receivedAtMilliseconds'); expect(client!.addressBook).toEqual([ { name: 'Lang', firstName: '', street: 'Weg', houseNumber: 3, postalCode: 20095, city: 'Hamburg', email: 'a@b', phone: '' }, ]); }); }); it("takes a DNS server's records from its /dns/hosts, and names the node in a warning", () => { const hosts = 'www.filius.de A 3600 141.99.2.10\nfilius.de MX 3600 mail.filius.de\nbroken.de A 3600 .1.2.3\n'; const server = flsNode('Rechner', { name: 'DNS', interfaces: [nic('10.0.0.1')], system: { applications: [{ key: 'dns', javaClass: 'filius.software.dns.DNSServer', source: flsNode('Rechner').source, isActive: true }], fileSystem: { kind: 'directory', name: 'root', children: [ { kind: 'directory', name: 'dns', children: [{ kind: 'file', name: 'hosts', type: 'text', content: hosts, children: [] }] }, ], }, }, }); const project = importProject(topology({ nodes: [server] }), counter()); const [id] = project.graph.nodes.map((node) => node.id); expect(project.dnsServerConfigurations.get(id!)?.records).toEqual([ { name: 'www.filius.de', type: 'A', ttlSeconds: 3600, target: '141.99.2.10' }, { name: 'filius.de', type: 'MX', ttlSeconds: 3600, target: 'mail.filius.de.' }, ]); expect(project.virtualFileSystems.get(id!)?.textFile('/dns/hosts')).toBe(hosts); expect(project.warnings).toEqual(["node 'DNS': DNS record 'broken.de A .1.2.3' has no usable IPv4 address, left out (it stays in /dns/hosts)"]); }); it('leaves out a cable to itself and one to a port that already has a cable', () => { const project = importProject( topology({ nodes: [flsNode('Switch', { portCount: 24 }), flsNode('Rechner', { interfaces: [nic('10.0.0.1')] })], links: [ { ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false, panel: [null, null] }, { ends: [{ node: 1, port: 0 }, { node: 0, port: 2 }], wireless: false, panel: [null, null] }, { ends: [{ node: 0, port: 3 }, { node: 1, port: 0 }], wireless: false, panel: [null, null] }, ], }), counter(), ); expect(project.graph.links).toHaveLength(1); expect(project.warnings).toEqual([ 'cable 1: connects a node to itself, left out', 'cable 3: a port already has a cable, left out', ]); }); it('leaves out documentation items without size or of an unknown type, and colors the one without color', () => { const item = { text: 'Hallo', x: 10, y: 20, width: 100, height: 30, font: null }; const project = importProject( topology({ documentation: [ { ...item, type: 2, color: null }, { ...item, type: 1, width: 0, color: [1, 2, 3, 255] }, { ...item, type: 3, color: [1, 2, 3, 255] }, { ...item, type: 1, text: null, color: null }, { ...item, type: 2, width: 15, height: 10, color: [1, 2, 3, 255], font: { name: 'Serif', style: 3, size: 6 } }, { ...item, type: 2, color: [1, 2, 3, 255], font: { name: 'Dialog', style: 2, size: 18 } }, ], }), counter(), ); // Without a colour, light grey, as Java shows it (JDocuElement.setColor(null)), either kind; italic kept; a small size and font size kept. const grey = { red: 192 / 255, green: 192 / 255, blue: 192 / 255, alpha: 1 }; const rgb = { red: 1 / 255, green: 2 / 255, blue: 3 / 255, alpha: 1 }; expect(project.documentationItems.map((doc) => [doc.kind, doc.text, doc.color, doc.fontName, doc.fontSize, doc.isBold, doc.isItalic, doc.frame.width, doc.frame.height])).toEqual([ ['rectangle', '', grey, 'Dialog', 12, false, false, 100, 30], ['text', 'Hallo', grey, 'Dialog', 12, false, false, 100, 30], ['text', 'Hallo', rgb, 'Serif', 6, true, true, 15, 10], ['text', 'Hallo', rgb, 'Dialog', 18, false, true, 100, 30], ]); expect(project.warnings).toEqual([ 'documentation item 1 without a color, given the default', 'documentation item without size, left out', 'documentation item of type 3, left out (known: RECT = 1, TEXT = 2)', 'documentation item 2 without a color, given the default', ]); }); });