The servers share ServerAnwendung's frame (server.ts); the web server serves /webserver with virtual hosts, the browser loads a page and its images over one connection to port 80 (web.ts); HTTP in Java's text form (http.ts). WebRun records eleven stories on the new web-lan network, with the server's files; webJava.test.ts plays them card by card. A program's close is a request, as Java's schliessen. Not taken from Java: /../ serving any file of the host; an unreadable request closing the connection without a word. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1196 lines
53 KiB
TypeScript
1196 lines
53 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('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 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)',
|
|
]);
|
|
});
|
|
});
|