mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Error responses now carry the registered name of the thrown error's class, and the requester rejects with an instance of that class instead of a generic RequestError. Error classes are registered like message classes, and plugins list their own in `errors`. RequestError is replaced by a hierarchy following the HTTP status codes: ClientError (ValidationError, ForbiddenError, NotFoundError, TimeoutError, ConflictError) and ServerError (InternalServerError). PermissionError is PermissionDeniedError and the config errors InvalidValue, InvalidField and InvalidAccess gain an Error suffix. Link logs only errors whose class is not registered. These arrive on the other side as InternalServerError with the original message and stack, so an error forwarded through the controller is logged once, where it was thrown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
817 lines
32 KiB
JavaScript
817 lines
32 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,
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
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(".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.mainBundle = "static/main.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)"));
|
|
});
|
|
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)"));
|
|
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 ConflictError("Host containing instance is not connected")
|
|
);
|
|
});
|
|
it("should error on invalid host", function() {
|
|
assert.throws(
|
|
() => controller.sendRequest(new MockEvent(), Address.fromShorthand({ hostId: 99 })),
|
|
new ConflictError("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,
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
});
|