Files
mdg-filius/packages/fls/test/importProject.test.ts
T
julian.kiedaischandClaude Opus 5.5 0abce60aef test: F2 acceptance — five of FILIUS' examples run as Java FILIUS runs them
ExampleRun opens an example as FILIUS opens it (SzenarioVerwaltung.laden
into the window's lists), starts it as the action mode does, plays a
story and writes every card's frames and tables, the calls, the
addresses and the RIP tables' changes. examplesJava.test.ts reads the
same file with this package's reader and runs it in the core
(projectSnapshot, new): routing (internetwork_komplett), DHCP
(einfaches_rechnernetz_komplett), DNS (dns_server), NAT
(nat_with_mapped_port) and firewall (Internet_Komplett_..._DE) match
card by card.

Found and fixed: a host's ipForwardingEnabled, which Java saves and
obeys, was unknown to the reader, the model, the core, the writer, the
export and FlsOracle; and Java's ARP learns only from a request for its
own address or a reply. Named Java bugs: a ping without a gateway throws
a NullPointerException; a home router drops the answer of a second
remote end. Open: Java picks between equally long RIP routes by thread
timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:57:10 +00:00

1149 lines
51 KiB
TypeScript

// 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,
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,
projectSnapshot,
readFlsArchive,
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<string, SwiftSummary>;
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<string, unknown> = {};
const switchConfiguration = project.switchConfigurations.get(nodeId);
if (switchConfiguration !== undefined) {
result.switch = {
ssid: switchConfiguration.ssid === defaultSwitchConfiguration(nodeId).ssid ? '<from node id>' : 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<Record<NodeKind, string>> = {
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<string, unknown>; 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<string, Record<number, (node: SwiftNode) => 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 `<object idref="Boolean0"/>`, 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 `<object idref="Integer0"/>`, 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> = {}): FlsNode {
return {
javaClass,
name: javaClass,
x: 0,
y: 0,
useIpAsName: false,
useMacAsName: 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>): 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 only the DNS servers that are switched 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.keys()]).toEqual([on]);
});
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> = {}): 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> = {}): 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> = {}): JavaEmail => ({
sender: { name: 'Alice', mailAddress: 'alice@senior.de' },
to: ['Bob <bob@filius.de>'],
cc: [],
bcc: [],
subject,
text: 'Hi',
dateReceived: '',
isNew: true,
isDeleted: false,
isSent: false,
...fields,
});
const host = (application: Partial<FlsApplication>) =>
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<FlsApplication>) =>
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] },
],
}),
counter(),
);
expect(project.documentationItems.map((doc) => [doc.kind, doc.text, doc.color, doc.fontName, doc.fontSize])).toEqual([
['text', 'Hallo', { red: 0, green: 0, blue: 0, alpha: 1 }, 'Dialog', 12],
]);
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)',
]);
});
});