Files
factorio-clusterio/test/controller/Controller.js
bbassieandClaude Opus 5.5 01cf789f5d Add base instances for instance config inheritance
Instances can be marked as a base with instance.is_base and other
instances inherit config values from it through instance.base_id.
The controller copies base values into inheriting configs and keeps
track of inherited fields in the hidden instance.inherited_fields, so
hosts receive resolved configs and need no changes. Setting a field
stops inheriting it, `instance config inherit` undoes that.

Bases cannot be assigned to a host, are hidden from `instance list`
unless --bases is given, and cannot be deleted or unmarked while
other instances inherit from them. Chains of bases are allowed and
cycles are rejected.

Part of #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:54:21 +00:00

932 lines
37 KiB
JavaScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import assert from "node:assert/strict";
import { Controller, HostRecord, InstanceRecord, UserRecord } from "@clusterio/controller";
import events, { EventEmitter } from "node:events";
import http from "node:http";
import express from "express";
import { fileURLToPath } from "node:url";
import {
ControllerConfig, Address, ConflictError, NotFoundError, UnavailableError,
InstanceConfig, SystemInfo, Role, ModPack, AccountUpdateEvent,
addPluginConfigFields, renamePermissionPrefix,
} from "@clusterio/lib";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
class MockEvent {}
class MockInstanceConfig extends EventEmitter {
constructor(data = new Map()) {
super();
this._data = data;
}
get(field) {
return this._data.get(field);
}
set(field, value) {
const prev = this.get(field);
this._data.set(field, value);
this.emit("fieldChanged", field, value, prev);
}
isInherited() {
return false;
}
}
describe("controller/src/Controller", function() {
describe("class Controller", async function() {
/** @type {Controller} */
let controller, mockInstanceConfig;
const controllerVersion = (
await import("@clusterio/controller/package.json", { with: { type: "json" }})
).default.version;
before(async function() {
const controllerConfig = new ControllerConfig("controller", { "controller.version": controllerVersion });
controller = new Controller({}, [], controllerConfig);
mockInstanceConfig = new MockInstanceConfig(new Map([
["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []],
]));
await controller.instances.createInstance(mockInstanceConfig);
});
function check(list, ip, present = true) {
if (present === false) {
assert(!list.check(ip), `${ip} unexpectedly in list`);
} else {
assert(list.check(ip), `${ip} missing from list`);
}
}
describe(".fromConfig()", function() {
let dir;
beforeEach(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-controller-"));
});
afterEach(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should load datastores and mods from disk", async function() {
const databaseDirectory = path.join(dir, "database");
const modsDirectory = path.join(dir, "mods");
await fs.mkdir(databaseDirectory);
await fs.writeFile(path.join(databaseDirectory, "notes.json"), JSON.stringify([
{ resourceType: "instance", resourceId: "100", content: "hello" },
]));
const config = new ControllerConfig("controller", {
"controller.version": controllerVersion,
"controller.database_directory": databaseDirectory,
"controller.mods_directory": modsDirectory,
});
const loaded = await Controller.fromConfig(config, [], {}, true, false);
assert(loaded instanceof Controller);
assert.equal(loaded.canRestart, true);
assert.equal(loaded.recoveryMode, false);
assert.equal(loaded.notes.get("instance/100").content, "hello");
assert.equal(loaded.modStore.modsDirectory, modsDirectory);
assert(loaded.roles.size > 0, "default roles were not added");
assert(loaded.modPacks.size > 0, "default mod packs were not added");
await fs.access(modsDirectory);
});
it("should start and stop with an existing database", async function() {
this.timeout(10000);
const databaseDirectory = path.join(dir, "database");
await fs.mkdir(databaseDirectory);
const records = {
"hosts.json": [new HostRecord(5, "existing-host", controllerVersion, new Map(), true)],
"instances.json": [
new InstanceRecord(new InstanceConfig("controller", {
"instance.id": 100, "instance.assigned_host": 5,
}), "running"),
new InstanceRecord(new InstanceConfig("controller", { "instance.id": 101 }), "unassigned"),
],
"mod-packs.json": [ModPack.fromJSON({ id: 7, name: "existing-pack" })],
"users.json": [new UserRecord(0, "admin", new Set([0]), new Set(), true)],
};
for (const [file, content] of Object.entries(records)) {
await fs.writeFile(path.join(databaseDirectory, file), JSON.stringify(content));
}
// Port 0 means not configured, so ask the OS for a free one.
const portProbe = http.createServer();
portProbe.listen(0, "127.0.0.1");
await events.once(portProbe, "listening");
const port = portProbe.address().port;
portProbe.close();
await events.once(portProbe, "close");
const config = new ControllerConfig("controller", {
"controller.version": controllerVersion,
"controller.database_directory": databaseDirectory,
"controller.mods_directory": path.join(dir, "mods"),
"controller.http_port": port,
"controller.bind_address": "127.0.0.1",
});
const errors = [];
const errorCapture = new lib.ConsoleTransport({
level: "error",
filter: info => {
// pnpm build does not produce the production web bundle
if (!info.message.includes("Missing main entry")) {
errors.push(info.message);
}
return false;
},
});
lib.logger.add(errorCapture);
let loaded;
try {
loaded = await Controller.fromConfig(config, [], lib.logger);
await loaded.start({ logDirectory: path.join(dir, "logs"), _: ["run"] });
assert.deepEqual(
[...loaded.instances.values()].map(instance => [instance.id, instance.status]),
[[100, "unknown"], [101, "unassigned"]],
);
assert.equal(loaded.hosts.get(5).connected, false);
assert.equal(loaded.modPacks.get(7).name, "existing-pack");
assert(loaded.users.getByName("admin"), "existing user was not loaded");
const response = await fetch(`http://127.0.0.1:${port}/`);
assert.equal(response.status, 200);
await response.text();
} finally {
await loaded?.stop();
lib.logger.remove(errorCapture);
}
assert.deepEqual(errors, []);
const saved = JSON.parse(await fs.readFile(path.join(databaseDirectory, "instances.json"), "utf8"));
assert.deepEqual(saved.map(instance => instance.config["instance.id"]), [100, 101]);
});
describe("with a plugin version change", function() {
before(function() {
addPluginConfigFields([{ name: "from_config_plugin", version: "1.2.0", instanceEntrypoint: "x" }]);
});
afterEach(function() {
lib.Config.pluginVersions.clear();
});
after(function() {
for (const Config of [lib.ControllerConfig, lib.HostConfig, lib.InstanceConfig]) {
for (const name of Object.keys(Config.fieldDefinitions)) {
if (name.startsWith("from_config_plugin.")) {
delete Config.fieldDefinitions[name];
}
}
}
});
it("should save instances whose config changed while loading", async function() {
const databaseDirectory = path.join(dir, "database");
const instancesPath = path.join(databaseDirectory, "instances.json");
await fs.mkdir(databaseDirectory);
const instanceConfig = new InstanceConfig("controller", {
"instance.id": 7, "from_config_plugin.version": "1.0.0",
});
await fs.writeFile(instancesPath, JSON.stringify([
{ config: instanceConfig.toJSON(), status: "unassigned" },
]));
lib.Config.pluginVersions.set("from_config_plugin", "1.2.0");
const config = new ControllerConfig("controller", {
"controller.version": controllerVersion,
"controller.database_directory": databaseDirectory,
"controller.mods_directory": path.join(dir, "mods"),
});
const loaded = await Controller.fromConfig(config, [], {});
await loaded.instances.records.save();
const [saved] = JSON.parse(await fs.readFile(instancesPath, "utf8"));
assert.equal(saved.config["from_config_plugin.version"], "1.2.0");
});
});
});
describe(".validateExports()", function() {
let dir;
before(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-exports-"));
await fs.writeFile(path.join(dir, "locale.abc.json"), "[]");
await fs.writeFile(path.join(dir, "metadata.abc.json"), "{}");
});
after(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should drop export manifests with missing or invalid files", async function() {
const provider = new lib.MemoryDatastoreProvider();
const modPacks = new lib.SubscribableDatastore(provider, new Map());
const packs = [
{ id: 1, assets: { locale: "locale.abc.json", metadata: "metadata.abc.json" } },
{ id: 2, assets: { locale: "locale.abc.json", metadata: "metadata.def.json" } },
{ id: 3, assets: { locale: "../locale.abc.json" } },
{ id: 4 },
].map(({ id, assets }) => {
const modPack = ModPack.fromJSON({ id, name: `pack ${id}` });
if (assets) {
modPack.exportManifest = new lib.ExportManifest(assets);
}
return modPack;
});
modPacks.setMany(packs);
const updates = [];
modPacks.on("update", updated => updates.push(...updated));
await Controller.validateExports(modPacks, dir);
assert.deepEqual(modPacks.get(1).exportManifest.assets, packs[0].exportManifest.assets);
assert.equal(modPacks.get(2).exportManifest, undefined);
assert.equal(modPacks.get(3).exportManifest, undefined);
assert.equal(modPacks.get(4).exportManifest, undefined);
assert.deepEqual(updates.map(modPack => modPack.id), [2, 3]);
});
});
describe(".loadJsonObject()", function() {
let dir;
before(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-load-json-"));
await fs.writeFile(path.join(dir, "valid.json"), JSON.stringify({ "main.js": "main.123.js" }));
await fs.writeFile(path.join(dir, "invalid.json"), "{ not json");
});
after(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should parse an existing file", async function() {
assert.deepEqual(
await Controller.loadJsonObject(path.join(dir, "valid.json")),
{ "main.js": "main.123.js" },
);
});
it("should return an empty object for a missing file by default", async function() {
assert.deepEqual(await Controller.loadJsonObject(path.join(dir, "missing.json")), {});
});
it("should throw for a missing file when throwOnMissing is set", async function() {
await assert.rejects(
Controller.loadJsonObject(path.join(dir, "missing.json"), true),
{ code: "ENOENT" },
);
});
it("should throw for invalid JSON", async function() {
await assert.rejects(Controller.loadJsonObject(path.join(dir, "invalid.json")), SyntaxError);
});
});
describe(".loggableHeaders()", function() {
it("should redact credentials and keep other headers", function() {
const headers = {
"authorization": "Bearer secret",
"x-access-token": "secret",
"cookie": "a=secret",
"user-agent": "test",
};
assert.deepEqual(Controller.loggableHeaders(headers), {
"authorization": "[redacted]",
"x-access-token": "[redacted]",
"cookie": "[redacted]",
"user-agent": "test",
});
assert.equal(headers["x-access-token"], "secret");
});
});
describe(".serveWeb()", function() {
let server, port;
before(async function() {
const app = express();
app.locals.controller = { config: { get: () => "Test Cluster" } };
app.locals.webBundle = {
main: "static/main.abc.js",
imports: { "react": "static/shared-react.abc.js" },
};
for (let route of ["/", "/instances/:id/view"]) {
app.get(route, Controller.serveWeb(route));
}
server = http.createServer(app);
server.listen(0, "localhost");
await events.once(server, "listening");
port = server.address().port;
});
after(function() {
server.close();
});
it("should serve the root with static/ in staticRoot", async function() {
const html = await (await fetch(`http://localhost:${port}/`)).text();
assert(html.includes("<title>Test Cluster</title>"));
assert(html.includes("src=\"./static/main.abc.js\""));
assert(html.includes("new URL(\"./\", document.location)"));
assert(html.includes("new URL(\"./static/\", document.location)"));
assert(html.includes('{"imports":{"react":"./static/shared-react.abc.js"}}'));
});
it("should use relative roots for nested routes", async function() {
let html = await (await fetch(`http://localhost:${port}/instances/1/view`)).text();
assert(html.includes("src=\"../../static/main.abc.js\""));
assert(html.includes("new URL(\"../../\", document.location)"));
assert(html.includes("new URL(\"../../static/\", document.location)"));
assert(html.includes('{"imports":{"react":"../../static/shared-react.abc.js"}}'));
html = await (await fetch(`http://localhost:${port}/instances/1/view/`)).text();
assert(html.includes("src=\"../../../static/main.abc.js\""));
assert(html.includes("new URL(\"../../../static/\", document.location)"));
});
});
describe(".parseTrustedProxies()", function() {
it("should parse addresses", function() {
controller.config.set("controller.trusted_proxies", "10.0.0.1");
let list = controller.parseTrustedProxies();
check(list, "10.0.0.1");
check(list, "10.0.0.2", false);
controller.config.set("controller.trusted_proxies", "10.0.0.1, 10.0.0.2, 10.1.2.3");
list = controller.parseTrustedProxies();
check(list, "10.0.0.1");
check(list, "10.0.0.2");
check(list, "10.0.0.3", false);
});
it("should ignore invalid entries", function() {
controller.config.set("controller.trusted_proxies", "10.0.0.1, invalid, 0.0.0.0/200, 10.0.0.2");
const list = controller.parseTrustedProxies();
check(list, "10.0.0.1");
check(list, "10.0.0.2");
check(list, "10.0.0.3", false);
});
it("should parse CIDR blocks", function() {
controller.config.set("controller.trusted_proxies", "10.0.0.1/24, 192.168.0.16/31");
const list = controller.parseTrustedProxies();
check(list, "10.0.0.0");
check(list, "10.0.0.1");
check(list, "10.0.0.10");
check(list, "10.0.0.255");
check(list, "10.0.1.0", false);
check(list, "192.168.0.15", false);
check(list, "192.168.0.16");
check(list, "192.168.0.17");
check(list, "192.168.0.18", false);
});
});
describe(".sendRequest()", function() {
it("should error on loopback", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand("controller")),
new Error(`Message would return back to sender ${Address.fromShorthand("controller")}.`)
);
});
it("should error on invalid controller", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ controlId: 99 })),
new NotFoundError("Target control connection does not exist.")
);
});
it("should error on invalid instance", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 99 })),
new NotFoundError("Instance with ID 99 does not exist")
);
});
it("should error on unassigned instance", function() {
mockInstanceConfig.set("instance.assigned_host", null);
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new ConflictError("Instance is not assigned to a host")
);
});
it("should error on instance with disconnected host", function() {
mockInstanceConfig.set("instance.assigned_host", 99);
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new UnavailableError("Host containing instance is not connected")
);
});
it("should error on invalid host", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
new UnavailableError("Host is not connected")
);
});
it("should error on invalid broadcast", function() {
// This is different to sendEvent because requests do not support broadcasting
assert.throws(
() => controller.sendRequest(new MockEvent(), new Address(Address.broadcast, 99)),
new Error(`Unknown address type ${Address.broadcast}`)
);
});
it("should error on invalid address", function() {
assert.throws(
() => controller.sendRequest(new MockEvent(), new Address(99, 99)),
new Error("Unknown address type 99")
);
});
});
describe(".sendEvent()", function() {
it("should error on loopback", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand("controller")),
new Error(`Message would return back to sender ${Address.fromShorthand("controller")}.`)
);
});
it("should error on invalid controller", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ controlId: 99 })),
new Error("Target control connection does not exist.")
);
});
it("should error on invalid instance", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 99 })),
new Error("Instance with ID 99 does not exist")
);
});
it("should error on unassigned instance", function() {
mockInstanceConfig.set("instance.assigned_host", null);
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new Error("Instance is not assigned to a host")
);
});
it("should error on instance with disconnected host", function() {
mockInstanceConfig.set("instance.assigned_host", 99);
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ instanceId: 100 })),
new Error("Host containing instance is not connected")
);
});
it("should error on invalid host", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
new Error("Host is not connected")
);
});
it("should error on invalid broadcast", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), new Address(Address.broadcast, 99)),
new Error("Unexpected broadcast target 99")
);
});
it("should error on invalid address", function() {
assert.throws(
() => controller.sendEvent(new MockEvent(), new Address(99, 99)),
new Error("Unknown address type 99")
);
});
});
function mockPermissionChanges() {
const role = new Role(1, "Test", "", new Set(["core.log.follow"]));
const otherRole = new Role(2, "Other", "", new Set());
const user = { roles: new Set([role]) };
const otherUser = { roles: new Set([otherRole]) };
const sent = [];
const checked = [];
const connection = {
user,
logChecked: false,
send(message) { sent.push(message); },
checkLogPermission() { this.logChecked = true; },
};
const otherConnection = {
user: otherUser,
send(message) { sent.push(message); },
checkLogPermission() {},
};
const originalWsServer = controller.wsServer;
controller.wsServer = { controlConnections: new Map([[1, connection], [2, otherConnection]]) };
controller.subscriptions.checkLinkPermissions = (link, linkUser) => checked.push([link, linkUser]);
return {
role, user, connection, sent, checked,
restore() {
controller.wsServer = originalWsServer;
delete controller.subscriptions.checkLinkPermissions;
},
};
}
function assertAccountUpdate(sent, role) {
assert.equal(sent.length, 1);
assert(sent[0] instanceof AccountUpdateEvent);
assert.deepEqual(sent[0].roles, [{ name: role.name, id: role.id, permissions: [...role.permissions] }]);
}
describe(".userPermissionsUpdated()", function() {
let mocked;
beforeEach(function() { mocked = mockPermissionChanges(); });
afterEach(function() { mocked.restore(); });
it("should send an account update to connections of the user", function() {
controller.userPermissionsUpdated(mocked.user);
assertAccountUpdate(mocked.sent, mocked.role);
});
it("should recheck subscriptions and log following of the user", function() {
controller.userPermissionsUpdated(mocked.user);
assert.deepEqual(mocked.checked, [[mocked.connection, mocked.user]]);
assert(mocked.connection.logChecked, "log permission was not checked");
});
});
describe(".rolesUpdated()", function() {
let mocked;
beforeEach(function() { mocked = mockPermissionChanges(); });
afterEach(function() { mocked.restore(); });
it("should send an account update to connections with the role", function() {
controller.rolesUpdated([mocked.role]);
assertAccountUpdate(mocked.sent, mocked.role);
});
it("should recheck subscriptions and log following of users with the role", function() {
controller.rolesUpdated([mocked.role]);
assert.deepEqual(mocked.checked, [[mocked.connection, mocked.user]]);
assert(mocked.connection.logChecked, "log permission was not checked");
});
});
describe(".instances", function() {
it("should set the dirty flag if the config is updated", function() {
controller.instances.records.dirty = false;
mockInstanceConfig.set("instance.assigned_host", null);
assert(controller.instances.records.dirty === true, "dirty flag was not set");
});
});
describe(".checkRestartRequired()", function() {
before(function() {
addPluginConfigFields([{ name: "restart_test" }]);
});
beforeEach(function() {
// Would not be needed if controller setup was beforeEach
controller.config.set("controller.version", controllerVersion);
controller.config.set("restart_test.load_plugin", true);
controller.config.restartRequired = false;
controller.pluginInfos[0] = {
name: "restart_test",
packagePath: fileURLToPath(import.meta.resolve("@clusterio/controller/package.json")),
version: controllerVersion,
};
});
it("returns false when no changes are present", async function() {
controller.pluginInfos[0].webEntrypoint = true;
controller.pluginInfos[0].controllerEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, false);
assert.equal(controller.config.restartRequired, false);
});
it("returns false when an unloaded plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].webEntrypoint = true;
controller.pluginInfos[0].controllerEntrypoint = true;
controller.config.set("restart_test.load_plugin", false);
controller.config.restartRequired = false; // Setting load_plugin requires a restart
const result = await controller.checkRestartRequired();
assert.equal(result, false);
assert.equal(controller.config.restartRequired, false);
});
it("returns true when the config flag is set", async function() {
controller.config.restartRequired = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when clusterio version changes", async function() {
controller.config.set("controller.version", "0.0.0");
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when a controller plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].controllerEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
it("returns true when a web plugin version changes", async function() {
controller.pluginInfos[0].version = "0.0.0";
controller.pluginInfos[0].webEntrypoint = true;
const result = await controller.checkRestartRequired();
assert.equal(result, true);
assert.equal(controller.config.restartRequired, true);
});
});
describe(".checkRestartDowngrade()", function() {
it("returns null when the installed version matches", async function() {
controller.config.set("controller.version", controllerVersion);
assert.equal(await controller.checkRestartDowngrade(), null);
});
it("returns null when the installed version is newer", async function() {
controller.config.set("controller.version", "0.0.0");
assert.equal(await controller.checkRestartDowngrade(), null);
});
it("returns both versions when the installed version is older", async function() {
controller.config.set("controller.version", "999.0.0");
assert.deepEqual(await controller.checkRestartDowngrade(), {
installedVersion: controllerVersion,
runningVersion: "999.0.0",
});
});
});
describe(".factorioVersions", function() {
const _fetch = global.fetch;
const fetchCalls = [];
before(function() {
global.fetch = async (url) => {
fetchCalls.push(url);
return { ok: false, status: 503, statusText: "Service Unavailable" };
};
});
it("should handle fetch failure", async function() {
await assert.deepEqual(
await controller.factorioVersions.get(0),
[],
);
assert(fetchCalls.length === 1, "expected one fetch call");
assert(fetchCalls[0].indexOf("/download") !== -1, "fetch call to be to a URL with /download in it");
});
after(function() {
global.fetch = _fetch;
});
});
});
describe("finalise", function() {
describe(".finaliseHosts()", function() {
it("resets the connected state to false", function() {
const hostInfo = new HostRecord(1, "", "", new Map(), true);
Controller.finaliseHosts(hostInfo);
assert.equal(hostInfo.connected, false);
assert(hostInfo.updatedAtMs > 0, "updatedAtMs not incremented");
});
it("does not increment updatedAtMs when there are no changes", function() {
const hostInfo = new HostRecord(1, "", "", new Map(), false);
Controller.finaliseHosts(hostInfo);
assert.equal(hostInfo.connected, false);
assert(hostInfo.updatedAtMs === 0, "updatedAtMs incremented");
});
});
describe(".finaliseInstances()", function() {
it("resets the stats for assigned instances", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
const instanceRecord = new InstanceRecord(instanceConfig, "running");
Controller.finaliseInstances(instanceRecord);
assert.equal(instanceRecord.status, "unknown");
assert(instanceRecord.updatedAtMs > 0, "updatedAtMs not incremented");
});
it("resets the status for unassigned instances", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
const instanceRecord = new InstanceRecord(instanceConfig, "running");
Controller.finaliseInstances(instanceRecord);
assert.equal(instanceRecord.status, "unassigned");
assert(instanceRecord.updatedAtMs > 0, "updatedAtMs not incremented");
});
it("does not update updatedAtMs when there are no changes (assigned)", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 });
const instanceRecord = new InstanceRecord(instanceConfig, "unknown");
Controller.finaliseInstances(instanceRecord);
assert.equal(instanceRecord.status, "unknown");
assert(instanceRecord.updatedAtMs === 0, "updatedAtMs incremented");
});
it("does not update updatedAtMs when there are no changes (unassigned)", function() {
const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null });
const instanceRecord = new InstanceRecord(instanceConfig, "unassigned");
Controller.finaliseInstances(instanceRecord);
assert.equal(instanceRecord.status, "unassigned");
assert(instanceRecord.updatedAtMs === 0, "updatedAtMs incremented");
});
});
describe(".finaliseUsers()", function() {
it("returns the user when there is no existing user", function() {
const user = new UserRecord(0, "TestUser", new Set([1]));
user.playerStats.onlineTimeMs = 100;
const users = new Map();
const result = Controller.finaliseUsers(user, users);
assert.equal(users.size, 0);
assert.equal(result, user);
});
it("does not merge users with different ids", function() {
const existingUser = new UserRecord(0, "UserOne", new Set([1]));
existingUser.playerStats.onlineTimeMs = 100;
const otherUser = new UserRecord(0, "UserTwo", new Set([2]));
otherUser.playerStats.onlineTimeMs = 50;
const users = new Map([[existingUser.id, existingUser]]);
const result = Controller.finaliseUsers(otherUser, users);
assert.equal(users.size, 1);
assert.equal(result, otherUser);
assert(!result.roleIds.has(1), "users were merged");
});
it("merges into existing user when onlineTimeMs is lower", function() {
const existingUser = new UserRecord(0, "TestUser", new Set([1]));
existingUser.playerStats.onlineTimeMs = 100;
const user = new UserRecord(0, "TestUser", new Set([2]));
user.playerStats.onlineTimeMs = 50;
const users = new Map([[existingUser.id, existingUser]]);
const result = Controller.finaliseUsers(user, users);
assert.equal(users.size, 1);
assert.equal(result, existingUser);
assert(result.roleIds.has(2), "other user not merged");
});
it("merges into existing user when onlineTimeMs is equal", function() {
const existingUser = new UserRecord(0, "TestUser", new Set([1]));
existingUser.playerStats.onlineTimeMs = 100;
const user = new UserRecord(0, "TestUser", new Set([2]));
user.playerStats.onlineTimeMs = 100;
const users = new Map([[existingUser.id, existingUser]]);
const result = Controller.finaliseUsers(user, users);
assert.equal(users.size, 1);
assert.equal(result, existingUser);
assert(result.roleIds.has(2), "other user not merged");
});
it("merges existing user into new user when onlineTimeMs is higher", function() {
const existingUser = new UserRecord(0, "TestUser", new Set([1]));
existingUser.playerStats.onlineTimeMs = 50;
const user = new UserRecord(0, "TestUser", new Set([2]));
user.playerStats.onlineTimeMs = 100;
const users = new Map([[existingUser.id, existingUser]]);
const result = Controller.finaliseUsers(user, users);
assert.equal(users.size, 1);
assert.equal(result, user);
assert(result.roleIds.has(1), "existing user not merged");
});
});
});
describe("migrations", function() {
describe(".migrateSystems()", function() {
it("migrates 'canRestart' from undefined to false", function() { // Alpha 17
const result = Controller.migrateSystems([
{}, { canRestart: true }, { canRestart: false },
]);
assert.equal(result[0].canRestart, false);
assert.equal(result[1].canRestart, true);
assert.equal(result[2].canRestart, false);
});
it("migrates 'restartRequired' from undefined to false", function() { // Alpha 21
const result = Controller.migrateSystems([
{}, { restartRequired: true }, { restartRequired: false },
]);
assert.equal(result[0].restartRequired, false);
assert.equal(result[1].restartRequired, true);
assert.equal(result[2].restartRequired, false);
});
it("migrates 'systemStartedAtMs' from undefined to Date.now", function() { // Alpha 23
const result = Controller.migrateSystems([
{}, { systemStartedAtMs: 0 }, { systemStartedAtMs: 500 },
]);
const now = Date.now();
const r0 = result[0].systemStartedAtMs;
assert.ok(r0 > now - 100 && r0 <= now);
assert.equal(result[1].systemStartedAtMs, 0);
assert.equal(result[2].systemStartedAtMs, 500);
});
it("migrates 'processStartedAtMs' from undefined to Date.now", function() { // Alpha 23
const result = Controller.migrateSystems([
{}, { processStartedAtMs: 0 }, { processStartedAtMs: 500 },
]);
const now = Date.now();
const r0 = result[0].processStartedAtMs;
assert.ok(r0 > now - 100 && r0 <= now);
assert.equal(result[1].processStartedAtMs, 0);
assert.equal(result[2].processStartedAtMs, 500);
});
it("does nothing for upto date data", function() {
const systemInfo = new SystemInfo(
1, "name", "n", "k", "m", "cm", "cr", "mc", "ma", "dc", "da", false, false, 0, 0, 0, false
);
const jsonString = JSON.stringify(systemInfo);
const systemInfoJson = JSON.parse(jsonString);
const systemInfoJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateSystems([
systemInfoJson, systemInfoJson, systemInfoJson,
]);
assert.deepEqual(result, [
systemInfoJsonCopy, systemInfoJsonCopy, systemInfoJsonCopy,
]);
});
});
describe(".migrateHosts()", function() {
it("migrates to the new format", function() { // Alpha 19
const result = Controller.migrateHosts([
["", "foo"], ["", "bar"], ["", "baz"],
]);
assert.deepEqual(result, [
"foo", "bar", "baz",
]);
});
it("does nothing for upto date data", function() {
const hostInfo = new HostRecord(1, "", "", new Map());
const jsonString = JSON.stringify(hostInfo);
const hostInfoJson = JSON.parse(jsonString);
const hostInfoJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateHosts([
hostInfoJson, hostInfoJson, hostInfoJson,
]);
assert.deepEqual(result, [
hostInfoJsonCopy, hostInfoJsonCopy, hostInfoJsonCopy,
]);
});
});
describe(".migrateInstances()", function() {
it("migrates to the new format", function() { // Alpha 14
const result = Controller.migrateInstances([
{ name: "foo" }, { name: "bar" }, { name: "baz" },
]);
assert.deepEqual(result, [
{ config: { name: "foo" }, status: "running" },
{ config: { name: "bar" }, status: "running" },
{ config: { name: "baz" }, status: "running" },
]);
});
it("does nothing for upto date data", function() {
const instanceConfig = new InstanceConfig("controller");
const instanceRecord = new InstanceRecord(instanceConfig, "unknown");
const jsonString = JSON.stringify(instanceRecord);
const instanceRecordJson = JSON.parse(jsonString);
const instanceRecordJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateInstances([
instanceRecordJson, instanceRecordJson, instanceRecordJson,
]);
assert.deepEqual(result, [
instanceRecordJsonCopy, instanceRecordJsonCopy, instanceRecordJsonCopy,
]);
});
});
describe(".migrateModPacks()", function() {
it("migrates builtin mod versions to X.Y.Z", function() {
const modPacks = Controller.migrateModPacks([{
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0" },
{ name: "my-mod", version: "1.0.0" },
],
}]);
assert.deepEqual(modPacks, [{
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0.0" },
{ name: "my-mod", version: "1.0.0" },
],
}]);
});
it("does nothing for upto date data", function() {
const modPack = ModPack.fromJSON({
id: 0,
name: "name",
factorio_version: "2.0",
mods: [
{ name: "base", version: "2.0.0" },
{ name: "my-mod", version: "1.0.0" },
],
});
const jsonString = JSON.stringify(modPack);
const modPackJson = JSON.parse(jsonString);
const modPackJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateModPacks([
modPackJson, modPackJson, modPackJson,
]);
assert.deepEqual(result, [
modPackJsonCopy, modPackJsonCopy, modPackJsonCopy,
]);
});
});
describe(".migrateRoles()", function() {
it("migrates renamed permissions", function() { // Alpha 17
const result = Controller.migrateRoles([
{ permissions: ["foo", "bar", "core.instance.save.list.subscribe"] },
{ permissions: ["bar", "core.instance.save.list.subscribe"] },
{ permissions: ["core.instance.save.subscribe"] },
]);
assert.deepEqual(result, [
{ permissions: ["foo", "bar", "core.instance.save.subscribe"] },
{ permissions: ["bar", "core.instance.save.subscribe"] },
{ permissions: ["core.instance.save.subscribe"] },
]);
});
it("runs plugin role migrations", function() {
Role.pluginMigrations.attach("test", role => (
renamePermissionPrefix(role, "old_name.", "new_name.")
));
try {
const result = Controller.migrateRoles([{ permissions: ["foo", "old_name.view"] }]);
assert.deepEqual(result, [{ permissions: ["foo", "new_name.view"] }]);
} finally {
Role.pluginMigrations.clear();
}
});
it("does nothing for upto date data", function() {
const role = new Role(
0, "n", "d", new Set("p1", "p2"), 0, false
);
const jsonString = JSON.stringify(role);
const roleJson = JSON.parse(jsonString);
const roleJsonCopy = JSON.parse(jsonString);
const result = Controller.migrateRoles([
roleJson, roleJson, roleJson,
]);
assert.deepEqual(result, [
roleJsonCopy, roleJsonCopy, roleJsonCopy,
]);
});
});
});
});