Merge remote-tracking branch 'origin/master' into fix/stream-reject-closes-connection

This commit is contained in:
Danielv123 committed 2026-11-10 21:32:23 +01:00
commit 60a4b103ab
142 files changed
+8147 -1306

No files matched your search

+300 -1
View File
@@ -1,8 +1,11 @@
import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { Controller, ControlConnection, ControllerHooks, InstanceManager } from "@clusterio/controller";
import {
Controller, ControlConnection, ControllerHooks, HostConnection, HostRecord, InstanceManager, InstanceRecord,
} from "@clusterio/controller";
import { MockConnector, MockController, MockLogger } from "../mock.js";
import "../setup_logging.js";
const addr = lib.Address.fromShorthand;
@@ -153,6 +156,142 @@ describe("controller/src/ControlConnection", function() {
assert.equal(clone.config.get("instance.name"), "clone");
assert.equal(clone.config.get("instance.assigned_host"), null);
});
it("creates an instance inheriting from a template", async function() {
await create({ "instance.id": 4001, "instance.name": "template", "instance.is_template": true });
mockController.instances.getMutable(4001).config.set("factorio.version", "2.0");
await create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 });
const child = mockController.instances.get(4002);
assert.equal(child.config.get("factorio.version"), "2.0");
assert.equal(child.config.isInherited("factorio.version"), true);
assert.equal(child.config.isInherited("instance.name"), false);
});
it("rejects a template that is not a template", async function() {
await create({ "instance.id": 4001, "instance.name": "plain" });
await assert.rejects(
create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 }),
new lib.ValidationError("Instance plain is not a template"),
);
assert.equal(mockController.instances.has(4002), false);
});
});
describe("instance config set handlers", function() {
let mockController;
let connection;
beforeEach(async function() {
mockController = new MockController();
mockController.mockConfigEntries.set("controller.name", "Test");
mockController.instances = new InstanceManager(new lib.SubscribableDatastore(), mockController);
mockController.instanceConfigUpdated = async () => {};
connection = {
_controller: mockController,
checkStagedInheritance: ControlConnection.prototype.checkStagedInheritance,
};
for (const [id, fields] of [
[1, { "instance.is_template": true, "factorio.version": "2.0" }],
[2, { "instance.template_id": 1 }],
]) {
const config = new lib.InstanceConfig("controller", {
"instance.id": id, "instance.name": `i${id}`, ...fields,
});
if (fields["instance.template_id"]) {
mockController.instances.applyTemplate(
config, ["instance.id", "instance.name", "instance.template_id"],
);
}
await mockController.instances.createInstance(config);
}
});
function call(handler, request) {
return ControlConnection.prototype[handler].call(connection, request);
}
async function create(config, cloneFromId) {
await ControlConnection.prototype.handleInstanceCreateRequest.call(
connection, new lib.InstanceCreateRequest(config, cloneFromId),
);
}
const setField = (...args) => new lib.InstanceConfigSetFieldRequest(...args);
const setFields = (...args) => new lib.InstanceConfigSetRequest(...args);
it("stops inheriting a field set to the inherited value", async function() {
await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.0"));
assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false);
});
it("stops inheriting a field set through set request", async function() {
await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.version": "2.0" }));
assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false);
});
it("stops inheriting a property set through set-prop", async function() {
await call(
"handleInstanceConfigSetPropRequest",
new lib.InstanceConfigSetPropRequest(2, "factorio.settings", "max_players", 0),
);
const config = mockController.instances.get(2).config;
assert.equal(config.isInherited("factorio.settings", "max_players"), false);
assert.equal(config.isInherited("factorio.settings", "auto_pause"), true);
});
it("rejects template_id pointing at a non-template", async function() {
await assert.rejects(
call("handleInstanceConfigSetFieldRequest", setField(1, "instance.template_id", "2")),
new lib.ValidationError("Instance i2 is not a template"),
);
assert.equal(mockController.instances.get(1).config.get("instance.template_id"), null);
});
it("rejects unmarking a template with inheritors through set request", async function() {
await assert.rejects(
call("handleInstanceConfigSetRequest", setFields(1, { "instance.is_template": "false" })),
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
);
assert.equal(mockController.instances.get(1).config.get("instance.is_template"), true);
});
it("stops inheriting only the properties set through set request", async function() {
await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.settings": { max_players: 3 } }));
const config = mockController.instances.get(2).config;
assert.equal(config.isInherited("factorio.settings", "max_players"), false);
assert.equal(config.isInherited("factorio.settings", "auto_pause"), true);
});
it("inherits a property again", async function() {
await call("handleInstanceConfigSetPropRequest", new lib.InstanceConfigSetPropRequest(
2, "factorio.settings", "max_players", 3,
));
await call(
"handleInstanceConfigInheritFieldRequest",
new lib.InstanceConfigInheritFieldRequest(2, "factorio.settings", "max_players"),
);
const config = mockController.instances.get(2).config;
assert.equal(config.get("factorio.settings").max_players, 0);
assert.equal(config.isInherited("factorio.settings", "max_players"), true);
});
it("creates a template from an existing instance", async function() {
await create({ "instance.id": 3, "instance.name": "copy", "instance.is_template": true }, 2);
const config = mockController.instances.get(3).config;
assert.equal(config.get("instance.is_template"), true);
assert.equal(config.get("instance.template_id"), 1);
assert.equal(config.get("factorio.version"), "2.0");
});
it("inherits a field again", async function() {
await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.1"));
await call(
"handleInstanceConfigInheritFieldRequest",
new lib.InstanceConfigInheritFieldRequest(2, "factorio.version"),
);
const config = mockController.instances.get(2).config;
assert.equal(config.get("factorio.version"), "2.0");
assert.equal(config.isInherited("factorio.version"), true);
});
});
describe("close cleanup", function() {
@@ -272,4 +411,164 @@ describe("controller/src/ControlConnection", function() {
assert.equal(player.connector.terminated, true);
});
});
describe("host management", function() {
/** @type {Controller} */
let controller;
/** @type {ControlConnection} */
let connection;
let audits;
let originalAudit;
beforeEach(function() {
const config = new lib.ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
controller = new Controller(lib.logger, [], config);
const connector = new lib.VirtualConnector(addr("controller"), addr({ controlId: 1 }));
const user = controller.users.getOrCreateUser("admin");
connection = new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1);
audits = [];
originalAudit = lib.logger.audit;
lib.logger.audit = msg => { audits.push(msg); };
});
afterEach(function() {
lib.logger.audit = originalAudit;
});
describe(".handleHostRevokeRequest()", function() {
let record;
let broadcasts;
beforeEach(function() {
record = new HostRecord(10, "host-10", "", new Map());
controller.hosts.records.set(record);
broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof lib.HostUpdatesEvent) {
broadcasts.push(...event.updates.map(host => ({ id: host.id, isDeleted: host.isDeleted })));
}
};
});
function assignInstance(id, name, hostId) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.set("instance.id", id);
instanceConfig.set("instance.name", name);
instanceConfig.set("instance.assigned_host", hostId);
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
}
async function revoke(hostId = 10) {
await connection.handleHostRevokeRequest(new lib.HostRevokeRequest(hostId));
}
it("deletes the host record", async function() {
await revoke();
assert.equal(controller.hosts.has(10), false);
assert.deepEqual(broadcasts, [{ id: 10, isDeleted: true }]);
assert.equal(audits.length, 1);
assert.match(audits[0], /User admin revoked host host-10 \(10\)/);
});
it("deletes the note of the host", async function() {
controller.setNote("host", "10", "rack 3", "someone");
controller.setNote("host", "11", "rack 4", "someone");
await revoke();
const note = controller.notes.get("host/10");
assert.equal(note.isDeleted, true);
assert.equal(note.content, "");
assert.equal(note.updatedBy, "admin");
assert.equal(controller.notes.get("host/11").isDeleted, false);
});
it("terminates the connection of the host", async function() {
let terminated = false;
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
await revoke();
assert.equal(terminated, true);
});
it("does not leave a host record behind when the connection closes", async function() {
const connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
connector.terminate = () => { connector.emit("close"); };
const hostConnection = new HostConnection(
new lib.RegisterHostData("", "2.0.0", 10, {}), connector, controller, "10.0.0.1"
);
controller.wsServer.hostConnections.set(10, hostConnection);
assert.equal(controller.hosts.get(10).connected, true);
await revoke();
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(broadcasts.at(-1), { id: 10, isDeleted: true });
});
it("unassigns the instances of the host without waiting for it", async function() {
let closing = false;
controller.wsServer.hostConnections.set(10, {
connector: { terminate() { closing = true; }, get closing() { return closing; } },
send: () => new Promise(() => {}),
});
assignInstance(101, "alpha", 10);
assignInstance(102, "beta", 10);
assignInstance(201, "gamma", 20);
await revoke();
assert.equal(controller.hosts.has(10), false);
for (const id of [101, 102]) {
const instance = controller.instances.get(id);
assert.equal(instance.config.get("instance.assigned_host"), null);
assert.equal(instance.status, "unassigned");
}
assert.equal(controller.instances.get(201).config.get("instance.assigned_host"), 20);
});
it("removes the system metrics of the host", async function() {
controller.systems.set(await lib.gatherSystemInfo(10, false, false, 0));
controller.systems.set(await lib.gatherSystemInfo("controller", false, false, 0));
await revoke();
assert.equal(controller.systems.has(10), false);
assert.equal(controller.systems.has("controller"), true);
});
it("throws NotFoundError for an unknown host", async function() {
await assert.rejects(revoke(99), new lib.NotFoundError("Unknown host id (99)"));
assert.equal(controller.hosts.has(10), true);
});
});
describe("host pairing requests", function() {
it("lists pending requests", async function() {
const first = controller.hostPairing.create("Host A", "2.0.0", "10.0.0.1");
const second = controller.hostPairing.create("Host B", "2.0.0", "10.0.0.2");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert(list.every(details => details instanceof lib.HostPairingDetails));
assert.deepEqual(list.map(details => details.code).sort(), [first.code, second.code].sort());
assert.equal(list.find(details => details.code === first.code).name, "Host A");
});
it("leaves out expired, approved and rejected requests when listing", async function() {
const expired = controller.hostPairing.create("Host A", "", "10.0.0.1");
const approved = controller.hostPairing.create("Host B", "", "10.0.0.2");
const rejected = controller.hostPairing.create("Host C", "", "10.0.0.3");
const pending = controller.hostPairing.create("Host D", "", "10.0.0.4");
controller.hostPairing._bySecret.get(expired.requestId).lastPollMs -= 60e3;
controller.hostPairing.approve(approved.code, undefined, "admin");
controller.hostPairing.reject(rejected.code, "admin");
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
assert.deepEqual(list.map(details => details.code), [pending.code]);
});
it("approves a request and returns the new host id", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
const hostId = await connection.handleHostPairingApproveRequest(
new lib.HostPairingApproveRequest(code.toLowerCase().replace("-", ""))
);
assert.equal(typeof hostId, "number");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(controller.hostPairing.poll(requestId).hostId, hostId);
assert(audits.some(msg => msg.startsWith(`User admin approved host Host A (${hostId})`)));
});
it("rejects a request", async function() {
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
await connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code.toLowerCase()));
assert.equal(controller.hostPairing.records.has(code), false);
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
assert.equal(controller.hosts.records.size, 0);
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
});
});
});
});
+110
View File
@@ -34,6 +34,10 @@ class MockInstanceConfig extends EventEmitter {
this._data.set(field, value);
this.emit("fieldChanged", field, value, prev);
}
isInherited() {
return false;
}
}
describe("controller/src/Controller", function() {
@@ -89,6 +93,75 @@ describe("controller/src/Controller", function() {
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" }]);
@@ -129,6 +202,43 @@ describe("controller/src/Controller", function() {
});
});
});
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() {
+97 -1
View File
@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { HostConnection } from "@clusterio/controller";
import { Controller, HostConnection, HostRecord } from "@clusterio/controller";
import { MockConnector } from "../mock.js";
const addr = lib.Address.fromShorthand;
function makeSave(instanceId, name) {
return new lib.SaveDetails(instanceId, "file", name, 0, 0, false, false, 0, false);
@@ -40,6 +43,99 @@ function makeInstances(assignments, names = new Map()) {
}
describe("controller/src/HostConnection", function() {
describe("host record", function() {
/** @type {Controller} */
let controller;
let record;
let connector;
let updates;
beforeEach(function() {
controller = new Controller(lib.logger, [], new lib.ControllerConfig("controller"));
record = new HostRecord(10, "host-10", "1.0.0", new Map(), false, "", "203.0.113.1", 123);
controller.hosts.records.set(record);
connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
updates = [];
controller.hosts.records.on("update", changed => {
updates.push(...changed.map(host => ({ id: host.id, name: host.name, isDeleted: host.isDeleted })));
});
});
function connect(id = 10) {
return new HostConnection(
new lib.RegisterHostData("", "2.0.0", id, {}), connector, controller, "10.0.0.1"
);
}
function revoke() {
controller.hosts.records.delete(record);
updates.length = 0;
}
it("should refuse a host without a record", function() {
assert.throws(() => connect(11), /Host 11 is not registered/);
assert.equal(controller.hosts.has(11), false);
assert.deepEqual(updates, []);
});
it("should update the existing record on connect", function() {
const connection = connect();
assert.equal(controller.hosts.get(10), record, "host record was replaced");
assert.equal(connection.id, 10);
assert.equal(record.connected, true);
assert.equal(record.version, "2.0.0");
assert.equal(record.remoteAddress, "10.0.0.1");
assert.equal(record.name, "host-10");
assert.equal(record.publicAddress, "203.0.113.1");
assert.equal(record.tokenValidAfter, 123);
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
});
it("should mark the host disconnected when the connection closes", function() {
connect();
updates.length = 0;
connector.emit("close");
assert.equal(controller.hosts.get(10), record);
assert.equal(record.connected, false);
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
});
it("should save info updates from the host", async function() {
const connection = connect();
updates.length = 0;
await connection.handleHostInfoUpdateEvent(
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
);
assert.equal(controller.hosts.get(10).name, "renamed");
assert.equal(controller.hosts.get(10).publicAddress, "198.51.100.1");
assert.deepEqual(updates, [{ id: 10, name: "renamed", isDeleted: false }]);
});
it("should not recreate the record of a revoked host when the connection closes", function() {
connect();
revoke();
connector.emit("close");
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(updates, []);
});
it("should not recreate the record of a revoked host on info updates", async function() {
const connection = connect();
revoke();
await connection.handleHostInfoUpdateEvent(
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
);
assert.equal(controller.hosts.has(10), false, "host record was recreated");
assert.deepEqual(updates, []);
});
it("should not overwrite a newer record with the same id", function() {
connect();
revoke();
const newer = new HostRecord(10, "newer", "", new Map());
controller.hosts.records.set(newer);
updates.length = 0;
connector.emit("close");
assert.equal(controller.hosts.get(10), newer);
assert.equal(newer.name, "newer");
assert.deepEqual(updates, []);
});
});
describe(".handleInstanceSaveDetailsUpdatesEvent()", function() {
// Instance 1 is on host 10, instance 2 is on host 20.
const assignments = new Map([[1, 10], [2, 20]]);
+123
View File
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostManager, HostRecord } from "@clusterio/controller";
import "../setup_logging.js";
describe("controller/src/HostManager", function() {
/** @type {Controller} */
let controller;
/** @type {HostManager} */
let hosts;
beforeEach(function() {
const config = new lib.ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
controller = new Controller(lib.logger, [], config);
hosts = controller.hosts;
});
it("should be the host manager of the controller", function() {
assert(hosts instanceof HostManager);
});
describe(".has() / .get() / .getMutable() / .values() / .getForRequest()", function() {
it("should return the host when it exists", function() {
const record = new HostRecord(5, "five", "", new Map());
hosts.records.set(record);
assert.equal(hosts.has(5), true);
assert.equal(hosts.get(5), record);
assert.equal(hosts.getMutable(5), record);
assert.deepEqual([...hosts.values()], [record]);
assert.equal(hosts.getForRequest(5), record);
});
it("should return undefined or throw when the host does not exist", function() {
assert.equal(hosts.has(5), false);
assert.equal(hosts.get(5), undefined);
assert.equal(hosts.getMutable(5), undefined);
assert.throws(() => hosts.getForRequest(5), new lib.NotFoundError("Unknown host id (5)"));
});
});
describe(".createHost()", function() {
let originalRandomInt;
afterEach(function() {
if (originalRandomInt) {
crypto.randomInt = originalRandomInt;
originalRandomInt = undefined;
}
});
it("should create a host record that its tokens are valid for", function() {
const before = Math.floor(Date.now() / 1000);
const host = hosts.createHost("Host A");
assert(host instanceof HostRecord);
assert(Number.isInteger(host.id), "id is not an integer");
assert(host.id >= 1 && host.id < 2 ** 31, `id ${host.id} out of range`);
assert.equal(hosts.get(host.id), host);
assert.equal(host.name, "Host A");
assert.equal(host.connected, false);
assert(host.tokenValidAfter >= before, "tokenValidAfter predates the call");
assert(host.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
assert.equal(hosts.verifyHostToken(hosts.signHostToken(host.id)), host);
});
it("should not reuse the id of an existing host", function() {
hosts.records.set(new HostRecord(5, "existing", "", new Map()));
const values = [5, 5, 6];
originalRandomInt = crypto.randomInt;
crypto.randomInt = (...args) => (values.length ? values.shift() : originalRandomInt(...args));
const host = hosts.createHost("Host A");
assert.equal(host.id, 6);
assert.equal(hosts.get(5).name, "existing");
assert.equal(hosts.get(6).name, "Host A");
});
});
describe(".signHostToken()", function() {
it("should return a host token for the id", function() {
const payload = jwt.verify(hosts.signHostToken(5), controller.authSecret);
const { iat, ...rest } = payload;
assert.equal(typeof iat, "number");
assert.deepEqual(rest, { aud: "host", host: 5 });
});
});
describe(".verifyHostToken()", function() {
beforeEach(function() {
hosts.records.set(new HostRecord(5, "five", "", new Map()));
});
it("should return the mutable host record", function() {
assert.equal(hosts.verifyHostToken(hosts.signHostToken(5)), hosts.getMutable(5));
});
it("should accept a token from before the slave rename", function() {
const token = jwt.sign({ aud: "slave", slave: 5 }, controller.authSecret);
assert.equal(hosts.verifyHostToken(token).id, 5);
});
it("should reject a token for an unknown host", function() {
assert.throws(() => hosts.verifyHostToken(hosts.signHostToken(6)), /host is not registered/);
});
it("should reject a token issued before tokenValidAfter", function() {
const now = Math.floor(Date.now() / 1000);
hosts.getMutable(5).tokenValidAfter = now;
const token = jwt.sign({ aud: "host", host: 5, iat: now - 1 }, controller.authSecret);
assert.throws(() => hosts.verifyHostToken(token), new Error("invalid token"));
});
it("should reject user tokens and bad signatures", function() {
const userToken = jwt.sign({ aud: "user", user: "test" }, controller.authSecret);
assert.throws(() => hosts.verifyHostToken(userToken), /audience invalid/);
const forged = jwt.sign({ aud: "host", host: 5 }, "wrong secret");
assert.throws(() => hosts.verifyHostToken(forged), /invalid signature/);
});
});
describe(".revokeHost()", function() {
it("should throw for an unknown host", async function() {
await assert.rejects(hosts.revokeHost(5, "admin"), new lib.NotFoundError("Unknown host id (5)"));
});
});
});
+461
View File
@@ -0,0 +1,461 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostPairingManager, HostPairingError } from "@clusterio/controller";
import {
pollTimeoutMs, maxPendingPerAddress, maxNameLength,
} from "@clusterio/controller/dist/node/src/HostPairingManager.js";
import "../setup_logging.js";
const codePattern = /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/;
function makeController() {
const config = new lib.ControllerConfig("controller");
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
return new Controller(lib.logger, [], config);
}
/**
* Make crypto.randomInt return the given values before falling back to random ones
*
* @param {number[]} values - Values to return, in order.
* @returns {() => void} function restoring crypto.randomInt.
*/
function stubRandomInt(values) {
const original = crypto.randomInt;
crypto.randomInt = (...args) => (values.length ? values.shift() : original(...args));
return () => { crypto.randomInt = original; };
}
function assertPairingError(fn, status, message) {
assert.throws(fn, err => {
assert(err instanceof HostPairingError, `expected HostPairingError, got ${err}`);
assert.equal(err.status, status);
if (message) {
assert.equal(err.message, message);
}
return true;
});
}
describe("controller/src/HostPairingManager", function() {
describe("HostPairingManager.validateName()", function() {
it("should require a non-empty string", function() {
for (const name of [undefined, null, 5, {}, "", " "]) {
assertPairingError(() => HostPairingManager.validateName(name), 400, "Host name is required");
}
});
it("should trim the name", function() {
assert.equal(HostPairingManager.validateName(" Host A \t"), "Host A");
});
it("should limit the length of the name", function() {
const longest = "x".repeat(maxNameLength);
assert.equal(maxNameLength, 100);
assert.equal(HostPairingManager.validateName(longest), longest);
assert.equal(HostPairingManager.validateName(` ${longest} `), longest);
assertPairingError(
() => HostPairingManager.validateName(`${longest}x`),
400, "Host name must be at most 100 characters",
);
});
it("should refuse control and bidirectional override characters", function() {
for (const name of ["x\u001b]0;title\u0007", "line\nfaked", "a\rb", "del\u007f", "c1\u009b", "x\u202ey"]) {
assertPairingError(
() => HostPairingManager.validateName(name), 400, "Host name may not contain control characters",
);
}
});
it("should allow other unicode characters", function() {
for (const name of ["Fabrikk p\u00e5 \u00d8stlandet", "\u5de5\u5382 1", "box \u{1F44D}\u200d"]) {
assert.equal(HostPairingManager.validateName(name), name);
}
});
});
describe("class HostPairingManager", function() {
/** @type {Controller} */
let controller;
/** @type {HostPairingManager} */
let manager;
let updates;
let restoreRandomInt;
beforeEach(function() {
controller = makeController();
manager = controller.hostPairing;
updates = [];
manager.records.on("update", changed => {
updates.push(...changed.map(details => ({ code: details.code, isDeleted: details.isDeleted })));
});
restoreRandomInt = undefined;
});
afterEach(function() {
manager.stop();
if (restoreRandomInt) {
restoreRandomInt();
}
});
function internal(requestId) {
return manager._bySecret.get(requestId);
}
// Pretend the host last polled the request ms milliseconds earlier.
function idle(requestId, ms) {
internal(requestId).lastPollMs -= ms;
}
describe(".create()", function() {
it("should create a pending request", function() {
const before = Date.now();
const { requestId, code } = manager.create("Host A", "2.0.0", "10.0.0.1");
assert.equal(typeof requestId, "string");
assert(requestId.length >= 32, "requestId is too short to be a secret");
assert.match(code, codePattern);
assert.notEqual(requestId, code);
const details = manager.records.get(code);
assert(details instanceof lib.HostPairingDetails);
assert.equal(details.code, code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "2.0.0");
assert.equal(details.remoteAddress, "10.0.0.1");
assert(details.createdAtMs >= before && details.createdAtMs <= Date.now());
assert(details.updatedAtMs > 0);
assert.equal(details.isDeleted, false);
assert(!JSON.stringify(details).includes(requestId), "requestId is exposed to users");
assert.deepEqual(updates, [{ code, isDeleted: false }]);
});
it("should create unique codes", function() {
const codes = new Set();
for (let i = 0; i < 50; i++) {
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
assert.match(code, codePattern);
codes.add(code);
}
assert.equal(codes.size, 50);
});
it("should not reuse the code of a pending request", function() {
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
assert.equal(manager.create("Host A", "", "10.0.0.1").code, "222-222");
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
it("should not reuse the code of an approved request not yet picked up", function() {
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
const { code } = manager.create("Host A", "", "10.0.0.1");
assert.equal(code, "222-222");
assert.equal(manager.approve(code, undefined, "admin"), 1234);
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
});
});
describe("limits", function() {
it("should limit pending requests per address", function() {
assert.equal(maxPendingPerAddress, 3);
for (let i = 0; i < maxPendingPerAddress; i++) {
manager.create("Host", "", "10.0.0.1");
}
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
assert(err instanceof HostPairingError);
assert.equal(err.status, 429);
assert.equal(err.retryAfter, 30);
assert.equal(err.message, "Too many pending requests from this address");
return true;
});
// Other addresses are not affected
manager.create("Host", "", "10.0.0.2");
assert.equal(manager.records.size, maxPendingPerAddress + 1);
});
it("should count IPv4 mapped addresses as the IPv4 address", function() {
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "::ffff:10.0.0.1");
manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "::ffff:10.0.0.1"), 429);
});
it("should group IPv6 addresses by /64", function() {
manager.create("Host", "", "2001:db8:0:1::1");
manager.create("Host", "", "2001:db8:0:1::2");
manager.create("Host", "", "2001:db8::1:ffff:0:0:3");
assertPairingError(
() => manager.create("Host", "", "2001:0db8:0000:0001:0000:0000:0000:0004"),
429, "Too many pending requests from this address",
);
manager.create("Host", "", "2001:db8:0:2::1");
});
it("should free the slot when a request is no longer pending", function() {
const first = manager.create("Host", "", "10.0.0.1");
const second = manager.create("Host", "", "10.0.0.1");
const third = manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
manager.cancel(first.requestId);
const fourth = manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
// Approved and rejected results waiting to be picked up do not count
manager.approve(second.code, undefined, "admin");
manager.reject(third.code, "admin");
manager.create("Host", "", "10.0.0.1");
manager.create("Host", "", "10.0.0.1");
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
assert(manager.records.has(fourth.code));
});
it("should limit the total number of pending requests", function() {
const maxPending = controller.config.get("controller.host_pairing_max_pending");
assert.equal(maxPending, 50);
for (let i = 0; i < maxPending; i++) {
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
}
assert.throws(() => manager.create("Host", "", "10.2.0.1"), err => {
assert(err instanceof HostPairingError);
assert.equal(err.status, 429);
assert.equal(err.retryAfter, 30);
assert.equal(err.message, "Too many pending requests");
return true;
});
assert.equal(manager.records.size, maxPending);
});
it("should not limit the total number of pending requests when set to 0", function() {
controller.config.set("controller.host_pairing_max_pending", 0);
for (let i = 0; i < 60; i++) {
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
}
assert.equal(manager.records.size, 60);
});
});
describe(".poll()", function() {
it("should return null while pending", function() {
const { requestId } = manager.create("Host A", "", "10.0.0.1");
assert.equal(manager.poll(requestId), null);
assert.equal(manager.poll(requestId), null);
});
it("should throw 404 for an unknown request", function() {
assertPairingError(() => manager.poll("invalid"), 404, "Request not found");
});
it("should not accept the code in place of the request id", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
assertPairingError(() => manager.poll(code), 404);
});
it("should hand over an approved result once", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, undefined, "admin");
const result = manager.poll(requestId);
assert.deepEqual(Object.keys(result).sort(), ["hostId", "name", "token"]);
assert.equal(result.hostId, hostId);
assert.equal(result.name, "Host A");
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, hostId);
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
assert.equal(manager._byCode.has(code), false);
});
it("should report a rejection once", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
assertPairingError(() => manager.poll(requestId), 403, "Request was rejected");
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
});
});
describe(".cancel()", function() {
it("should remove a pending request", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.cancel(requestId);
assert.equal(manager.records.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
});
it("should ignore unknown requests", function() {
manager.cancel("invalid");
});
it("should not throw away an approved result", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, undefined, "admin");
manager.cancel(requestId);
assert.equal(manager.poll(requestId).hostId, hostId);
});
it("should not throw away a rejection", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
manager.cancel(requestId);
assertPairingError(() => manager.poll(requestId), 403);
});
});
describe(".approve()", function() {
it("should create the host with the requested name", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.get(hostId).name, "Host A");
assert.equal(manager.records.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
});
it("should accept codes in any case and without the dash", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
assert(controller.hosts.has(hostId));
});
it("should use and trim the name given", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, " Renamed ", "admin");
assert.equal(controller.hosts.get(hostId).name, "Renamed");
assert.equal(manager.poll(requestId).name, "Renamed");
});
it("should reject an invalid name and keep the request pending", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
assert.throws(
() => manager.approve(code, "", "admin"),
new lib.ValidationError("Host name is required"),
);
assert.throws(
() => manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
new lib.ValidationError("Host name must be at most 100 characters"),
);
assert.equal(controller.hosts.records.size, 0);
assert.equal(manager.poll(requestId), null);
manager.approve(code, undefined, "admin");
assert.equal(controller.hosts.records.size, 1);
});
it("should throw NotFoundError for an unknown code", function() {
assert.throws(
() => manager.approve("ZZZ-ZZZ", undefined, "admin"),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should only approve a request once", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
manager.approve(code, undefined, "admin");
assert.throws(() => manager.approve(code, undefined, "admin"), lib.NotFoundError);
assert.equal(controller.hosts.records.size, 1);
});
});
describe(".reject()", function() {
it("should remove the request from the pending list", function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code.toLowerCase(), "admin");
assert.equal(manager.records.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assert.equal(controller.hosts.records.size, 0);
});
it("should throw NotFoundError for an unknown code", function() {
assert.throws(
() => manager.reject("ZZZ-ZZZ", "admin"),
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
);
});
it("should not reject an approved request", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, undefined, "admin");
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
assert.equal(manager.poll(requestId).hostId, hostId);
});
});
describe(".prune()", function() {
let warnings;
let originalWarn;
beforeEach(function() {
warnings = [];
originalWarn = lib.logger.warn;
lib.logger.warn = msg => { warnings.push(msg); };
});
afterEach(function() {
lib.logger.warn = originalWarn;
});
it("should expire a request the host stopped polling", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assert.equal(manager.records.has(code), false);
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
assertPairingError(() => manager.poll(requestId), 404);
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
});
it("should keep a request that is still polled", function() {
assert.equal(pollTimeoutMs, 30e3);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
idle(requestId, pollTimeoutMs - 1000);
manager.prune();
assert.equal(manager.poll(requestId), null);
idle(requestId, pollTimeoutMs - 1000);
manager.prune();
assert(manager.records.has(code));
});
it("should expire a request that has been pending for too long", function() {
const timeoutMs = controller.config.get("controller.host_pairing_timeout") * 1000;
assert.equal(timeoutMs, 60 * 60e3);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
internal(requestId).details.createdAtMs -= timeoutMs - 1000;
manager.prune();
assert.equal(manager.poll(requestId), null);
internal(requestId).details.createdAtMs -= 1001;
manager.prune();
assert.equal(manager.records.has(code), false);
assertPairingError(() => manager.poll(requestId), 404);
});
it("should not limit how long a request is pending when the timeout is 0", function() {
controller.config.set("controller.host_pairing_timeout", 0);
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
internal(requestId).details.createdAtMs -= 365 * 24 * 60 * 60e3;
manager.prune();
assert(manager.records.has(code));
assert.equal(manager.poll(requestId), null);
});
it("should drop and warn about an approved host never picked up", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
const hostId = manager.approve(code, undefined, "admin");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assertPairingError(() => manager.poll(requestId), 404);
assert.equal(warnings.length, 1);
assert.match(warnings[0], new RegExp(`Approved host Host A \\(${hostId}\\) was never picked up`));
// The host stays until revoked
assert(controller.hosts.has(hostId));
});
it("should drop a rejection never picked up", function() {
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
idle(requestId, pollTimeoutMs + 1);
manager.prune();
assertPairingError(() => manager.poll(requestId), 404);
assert.equal(warnings.length, 0);
});
});
describe("Controller integration", function() {
it("should broadcast changes to pending requests", function() {
const broadcasts = [];
controller.subscriptions.broadcast = event => {
if (event instanceof lib.HostPairingUpdatesEvent) {
broadcasts.push(event.updates.map(details => [details.code, details.isDeleted]));
}
};
const { code } = manager.create("Host A", "", "10.0.0.1");
manager.reject(code, "admin");
assert.deepEqual(broadcasts, [[[code, false]], [[code, true]]]);
});
it("should send pending requests to new subscribers", async function() {
const { code } = manager.create("Host A", "", "10.0.0.1");
const event = await controller.handleHostPairingSubscription(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, 0)
);
assert(event instanceof lib.HostPairingUpdatesEvent);
assert.deepEqual(event.updates.map(details => details.code), [code]);
const lastUpdate = manager.records.get(code).updatedAtMs;
assert.equal(await controller.handleHostPairingSubscription(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
), null);
});
});
});
});
+249
View File
@@ -393,4 +393,253 @@ describe("controller/InstanceManager", function () {
assert.equal(sentRequest.instanceId, 1);
});
});
describe("inheritance", function () {
/** @type {InstanceRecord} */
let template;
/** @type {InstanceRecord} */
let child;
let updated;
async function createInheritor(id, templateId, fields = {}) {
const config = new lib.InstanceConfig("controller", {
"instance.id": id, "instance.name": `Instance ${id}`, "instance.template_id": templateId, ...fields,
});
instances.applyTemplate(
config, ["instance.id", "instance.name", "instance.template_id", ...Object.keys(fields)]
);
return await instances.createInstance(config);
}
beforeEach(async function () {
template = await instances.createInstance(new lib.InstanceConfig("controller", {
"instance.id": 10,
"instance.name": "Template",
"instance.is_template": true,
"factorio.version": "2.0",
"factorio.game_port": 34190,
"factorio.settings": { name: "Template server", max_players: 5 },
}));
child = await createInheritor(11, 10, { "factorio.game_port": 34197 });
updated = [];
controller.instanceConfigUpdated = async (inst) => { updated.push(inst.id); };
});
describe(".applyTemplate()", function () {
it("should copy template values and mark them inherited", function () {
assert.equal(child.config.get("factorio.version"), "2.0");
assert.equal(child.config.isInherited("factorio.version"), true);
assert.equal(child.config.get("factorio.game_port"), 34197);
assert.equal(child.config.isInherited("factorio.game_port"), false);
assert.equal(child.config.isInherited("instance.name"), false);
});
it("should never inherit ports or the rcon password", function () {
template.config.set("factorio.rcon_password", "secret");
const config = new lib.InstanceConfig("controller", { "instance.id": 12, "instance.template_id": 10 });
instances.applyTemplate(config, ["instance.id", "instance.template_id"]);
assert.equal(config.get("factorio.game_port"), null);
assert.equal(config.isInherited("factorio.game_port"), false);
assert.equal(config.get("factorio.rcon_password"), null);
assert.equal(config.isInherited("factorio.rcon_password"), false);
});
it("should inherit factorio.settings per property with its own server name", function () {
const settings = child.config.get("factorio.settings");
assert.equal(settings.max_players, 5);
assert.equal(settings.name, "TestController - Instance 11");
assert.equal(child.config.isInherited("factorio.settings", "max_players"), true);
assert.equal(child.config.isInherited("factorio.settings", "name"), false);
});
it("should keep properties given by the creator as overrides", async function () {
const other = await createInheritor(12, 10, { "factorio.settings": { max_players: 8 } });
assert.equal(other.config.get("factorio.settings").max_players, 8);
assert.equal(other.config.isInherited("factorio.settings", "max_players"), false);
assert.equal(other.config.isInherited("factorio.settings", "auto_pause"), true);
});
});
describe("template changes", function () {
it("should propagate to inherited fields and send the config to the host", function () {
template.config.set("factorio.version", "2.1");
assert.equal(child.config.get("factorio.version"), "2.1");
assert.equal(child.config.isInherited("factorio.version"), true);
assert.deepEqual(updated, [11]);
});
it("should mark the inheritor as requiring restart", function () {
child.config.restartRequired = false;
template.config.set("factorio.version", "2.1");
assert.equal(child.config.restartRequired, true);
});
it("should not touch fields the inheritor has set", function () {
child.config.set("factorio.version", "2.1");
template.config.set("factorio.version", "2.2");
assert.equal(child.config.get("factorio.version"), "2.1");
});
it("should merge object properties and keep overrides", function () {
template.config.setProp("factorio.settings", "max_players", 10);
template.config.setProp("factorio.settings", "name", "Renamed");
template.config.setProp("factorio.settings", "auto_pause", true);
const settings = child.config.get("factorio.settings");
assert.equal(settings.max_players, 10);
assert.equal(settings.auto_pause, true);
assert.equal(settings.name, "TestController - Instance 11");
});
it("should propagate through several levels", async function () {
child.config.set("instance.is_template", true);
const grandchild = await createInheritor(12, 11);
template.config.setProp("factorio.settings", "max_players", 10);
assert.equal(grandchild.config.get("factorio.settings").max_players, 10);
assert.deepEqual(updated.sort(), [11, 12]);
});
});
describe("inheritor changes", function () {
it("should stop inheriting a field that is changed", function () {
child.config.set("factorio.version", "2.1");
assert.equal(child.config.isInherited("factorio.version"), false);
});
it("should only override the properties changed in an object", function () {
child.config.setProp("factorio.settings", "max_players", 3);
assert.equal(child.config.isInherited("factorio.settings", "max_players"), false);
assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), true);
});
it("should restore the template value of a removed property", function () {
child.config.setProp("factorio.settings", "max_players", 3);
child.config.setProp("factorio.settings", "max_players");
assert.equal(child.config.get("factorio.settings").max_players, 5);
assert.equal(child.config.isInherited("factorio.settings", "max_players"), true);
});
it("should stop inheriting with overrideField() even if the value is unchanged", function () {
instances.overrideField(child, "factorio.version");
instances.overrideField(child, "factorio.settings", ["auto_pause"]);
assert.equal(child.config.isInherited("factorio.version"), false);
assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), false);
});
});
describe(".inheritField()", function () {
it("should restore the template value", function () {
child.config.set("factorio.version", "2.1");
instances.inheritField(11, "factorio.version");
assert.equal(child.config.get("factorio.version"), "2.0");
assert.equal(child.config.isInherited("factorio.version"), true);
});
it("should restore a single property", function () {
child.config.setProp("factorio.settings", "max_players", 3);
instances.inheritField(11, "factorio.settings", "max_players");
assert.equal(child.config.get("factorio.settings").max_players, 5);
assert.equal(child.config.get("factorio.settings").name, "TestController - Instance 11");
});
it("should restore all properties", function () {
instances.inheritField(11, "factorio.settings");
assert.equal(child.config.get("factorio.settings").name, "Template server");
});
it("should throw if the instance has no template", function () {
assert.throws(
() => instances.inheritField(1, "factorio.version"),
new lib.ValidationError("Instance does not have a template"),
);
});
it("should throw for fields that cannot be inherited", function () {
assert.throws(
() => instances.inheritField(11, "instance.name"),
new lib.ValidationError("Field instance.name cannot be inherited"),
);
assert.throws(
() => instances.inheritField(11, "factorio.rcon_port"),
new lib.ValidationError("Field factorio.rcon_port cannot be inherited"),
);
});
});
describe("template_id changes", function () {
it("should inherit fields equal to the template or the default", function () {
instance.config.set("factorio.version", "2.0");
instance.config.set("factorio.verbose_logging", true);
template.config.set("instance.auto_start", true);
instance.config.set("instance.template_id", 10);
assert.equal(instance.config.isInherited("factorio.version"), true);
assert.equal(instance.config.isInherited("instance.auto_start"), true);
assert.equal(instance.config.get("instance.auto_start"), true);
assert.equal(instance.config.isInherited("factorio.verbose_logging"), false);
});
it("should inherit object properties equal to the template or the default", function () {
instance.config.setProp("factorio.settings", "max_players", 0);
instance.config.setProp("factorio.settings", "game_password", "secret");
instance.config.set("instance.template_id", 10);
const settings = instance.config.get("factorio.settings");
assert.equal(settings.max_players, 5);
assert.equal(settings.game_password, "secret");
assert.equal(instance.config.isInherited("factorio.settings", "game_password"), false);
assert.equal(instance.config.isInherited("factorio.settings", "max_players"), true);
});
it("should keep the server name of the instance", function () {
instance.config.setProp("factorio.settings", "name", "Template server");
instance.config.set("instance.template_id", 10);
assert.equal(instance.config.isInherited("factorio.settings", "name"), false);
});
it("should clear inherited fields when the template is removed", function () {
child.config.set("instance.template_id", null);
assert.deepEqual(child.config.get("instance.inherited_fields"), {});
assert.equal(child.config.get("factorio.version"), "2.0");
});
});
describe(".checkInheritance()", function () {
it("should accept a valid template", function () {
instances.checkInheritance(instance.config, false, 10);
});
it("should reject a missing template", function () {
assert.throws(
() => instances.checkInheritance(instance.config, false, 99),
new lib.NotFoundError("Instance with ID 99 does not exist"),
);
});
it("should reject an instance that is not a template", function () {
assert.throws(
() => instances.checkInheritance(instance.config, false, 11),
new lib.ValidationError("Instance Instance 11 is not a template"),
);
});
it("should reject cycles", function () {
child.config.set("instance.is_template", true);
assert.throws(
() => instances.checkInheritance(template.config, true, 11),
new lib.ValidationError("Templates cannot inherit from themselves"),
);
});
it("should reject making an assigned instance a template", function () {
instance.config.set("instance.assigned_host", 5);
assert.throws(
() => instances.checkInheritance(instance.config, true, null),
new lib.ConflictError("Instance must be unassigned before it can be made a template"),
);
});
it("should reject unmarking a template that has inheritors", function () {
assert.throws(
() => instances.checkInheritance(template.config, false, null),
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
);
});
});
it("should not assign a template to a host", async function () {
controller.wsServer.hostConnections.set(5, { send: async () => {}, connector: { closing: false } });
await assert.rejects(
instances.assignInstance(10, 5),
new lib.ValidationError("Templates cannot be assigned to a host"),
);
});
it("should not delete a template that has inheritors", async function () {
await assert.rejects(
instances.deleteInstance(10),
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
);
});
it("should report templates in instance details", function () {
assert.equal(template.toInstanceDetails().isTemplate, true);
assert.equal(child.toInstanceDetails().isTemplate, false);
});
});
});
+11
View File
@@ -153,6 +153,17 @@ describe("controller/InstanceRecord", function () {
assert.equal(details.startedAtMs, 100);
assert.equal(details.updatedAtMs, 200);
assert.equal(details.excludeFromStartAll, false);
assert.equal(details.modPackId, undefined);
});
it("should include the mod pack set in the config", function () {
const config = new lib.InstanceConfig("controller", {
"instance.id": 1,
"instance.name": "TestInstance",
"factorio.mod_pack_id": 3,
});
const details = new InstanceRecord(config, "stopped").toInstanceDetails();
assert.equal(details.modPackId, 3);
});
});
+25
View File
@@ -135,6 +135,31 @@ describe("controller/UserManager", function () {
});
});
describe(".verifyUserToken()", function () {
const secret = () => Buffer.from(config.get("controller.auth_secret"), "base64");
it("should return the user the token was signed for", function () {
const user = userManager.createUser("Judy");
assert.equal(userManager.verifyUserToken(userManager.signUserToken(user)).id, user.id);
});
it("should reject a token for an unknown user", function () {
const token = userManager.signUserToken({ id: "nobody" });
assert.throws(() => userManager.verifyUserToken(token), new Error("invalid user"));
});
it("should reject a token issued before tokenValidAfter", function () {
const user = userManager.createUser("Mallory");
const token = jwt.sign({ aud: "user", user: user.id, iat: user.tokenValidAfter - 1 }, secret());
assert.throws(() => userManager.verifyUserToken(token), new Error("invalid token"));
});
it("should reject host tokens and bad signatures", function () {
const user = userManager.createUser("Niaj");
const hostToken = jwt.sign({ aud: "host", host: 5 }, secret());
assert.throws(() => userManager.verifyUserToken(hostToken), /audience invalid/);
const forged = jwt.sign({ aud: "user", user: user.id }, "wrong secret");
assert.throws(() => userManager.verifyUserToken(forged), /invalid signature/);
});
});
describe(".clearStatsOfInstance()", function () {
it("should clear instance stats for all users", function () {
const instanceId = 42;
+126 -1
View File
@@ -1,7 +1,32 @@
import assert from "node:assert/strict";
import events from "node:events";
import { Controller, WsServer } from "@clusterio/controller";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import { Controller, HostRecord, WsServer } from "@clusterio/controller";
import { ControllerConfig } from "@clusterio/lib";
import packageJson from "@clusterio/controller/package.json" with { type: "json" };
import "../setup_logging.js";
class MockWebSocket extends events.EventEmitter {
constructor() {
super();
this.sent = [];
this.closed = null;
}
send(message) {
this.sent.push(JSON.parse(message));
}
close(code, reason) {
this.closed = { code, reason };
}
terminate() {
this.emit("close", 1006, "");
}
}
describe("controller/src/WsServer", function() {
describe("class WsServer", function() {
@@ -88,4 +113,104 @@ describe("controller/src/WsServer", function() {
});
});
});
describe(".registerHost()", function() {
const secret = Buffer.from("TestSecretDoNotUse");
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
/** @type {Controller} */
let controller;
/** @type {WsServer} */
let wsServer;
let socket;
beforeEach(function() {
const config = new ControllerConfig("controller");
config.set("controller.auth_secret", secret.toString("base64"));
controller = new Controller(lib.logger, [], config);
wsServer = controller.wsServer;
socket = new MockWebSocket();
});
afterEach(function() {
for (const connection of wsServer.hostConnections.values()) {
connection.connector.terminate();
}
});
function addHost(id, tokenValidAfter = 0) {
const record = new HostRecord(id, `host-${id}`, "", new Map(), false, "", "203.0.113.1", tokenValidAfter);
controller.hosts.records.set(record);
return record;
}
async function register(token, id = 5) {
await wsServer.registerHost(new lib.RegisterHostData(token, packageJson.version, id, {}), socket, req);
}
function assertRejected(reason) {
assert.deepEqual(socket.closed, { code: lib.ConnectionClosed.Unauthorized, reason });
assert.equal(socket.closed.code, 3000);
assert.equal(wsServer.hostConnections.size, 0);
assert(!socket.sent.some(message => message.type === "ready"), "ready was sent");
}
it("should reject a validly signed token for a host that is not registered", async function() {
await register(controller.hosts.signHostToken(5));
assertRejected(
"Authentication failed: host is not registered, start the host again to request to join the cluster"
);
assert.equal(controller.hosts.has(5), false, "host record was created");
});
it("should accept a registered host", async function() {
const record = addHost(5);
await register(controller.hosts.signHostToken(5));
assert.equal(socket.closed, null);
assert(socket.sent.some(message => message.type === "ready"), "ready was not sent");
assert(wsServer.hostConnections.has(5));
assert.equal(controller.hosts.records.size, 1);
assert.equal(controller.hosts.get(5), record, "host record was replaced");
assert.equal(record.connected, true);
assert.equal(record.version, packageJson.version);
assert.equal(record.remoteAddress, "10.0.0.1");
assert.equal(record.name, "host-5");
assert.equal(record.publicAddress, "203.0.113.1");
wsServer.hostConnections.get(5).connector.terminate();
assert.equal(wsServer.hostConnections.has(5), false);
assert.equal(controller.hosts.get(5).connected, false);
});
it("should reject a token issued before tokenValidAfter", async function() {
const token = controller.hosts.signHostToken(5);
addHost(5, Math.floor(Date.now() / 1000) + 60);
await register(token);
assertRejected("Authentication failed: invalid token");
});
it("should accept a token issued at tokenValidAfter", async function() {
const now = Math.floor(Date.now() / 1000);
addHost(5, now);
await register(jwt.sign({ aud: "host", host: 5, iat: now }, secret));
assert.equal(socket.closed, null);
assert(wsServer.hostConnections.has(5));
});
it("should reject a host revoked while its previous connection closes", async function() {
const record = addHost(5);
wsServer.hostConnections.set(5, {
remoteAddress: "10.0.0.1",
async disconnect() {
controller.hosts.records.delete(record);
wsServer.hostConnections.delete(5);
},
});
await register(controller.hosts.signHostToken(5));
assertRejected("Authentication failed: host was revoked");
assert.equal(controller.hosts.has(5), false, "host record was recreated");
});
it("should reject hosts in recovery mode", async function() {
addHost(5);
controller.recoveryMode = true;
await register(controller.hosts.signHostToken(5));
assert.deepEqual(socket.closed, {
code: lib.ConnectionClosed.RecoveryMode,
reason: "Connection Refused: Controller in recovery mode",
});
});
});
});
+335 -5
View File
@@ -1,8 +1,15 @@
import assert from "node:assert/strict";
import events from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import jwt from "jsonwebtoken";
import express from "express";
import * as lib from "@clusterio/lib";
import { HostPairingManager, HostRecord } from "@clusterio/controller";
import { wait, expositionContentType } from "@clusterio/lib";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.js";
@@ -100,7 +107,7 @@ describe("controller/src/routes", function() {
stream = await routes.createProxyStream(controller.app);
responses = await Promise.all([
(async function() {
await wait(100);
await lib.wait(100);
return await fetch(`${endpoint}/${stream.id}`, {
method: "PUT", body: "test content",
});
@@ -116,7 +123,7 @@ describe("controller/src/routes", function() {
method: "PUT", body: "test content",
}),
(async function() {
await wait(100);
await lib.wait(100);
return await fetch(`${endpoint}/${stream.id}`);
}()),
]);
@@ -163,7 +170,7 @@ describe("controller/src/routes", function() {
let response = await fetch(endpoint, { headers });
assert.equal(response.status, 200);
const contentType = response.headers.get("content-type");
for (const part of expositionContentType.split("; ")) {
for (const part of lib.expositionContentType.split("; ")) {
assert(contentType.includes(part), `expected ${part} in content type ${contentType}`);
}
assert(/^# HELP /m.test(await response.text()), "expected exposition text");
@@ -173,7 +180,7 @@ describe("controller/src/routes", function() {
let calls = 0;
controller.hooks.metrics.attach("test", async () => {
calls += 1;
await wait(100);
await lib.wait(100);
});
const headers = { "X-Access-Token": testToken };
let responses = await Promise.all([
@@ -190,6 +197,42 @@ describe("controller/src/routes", function() {
assert.equal(calls, 2);
});
});
describe("missingExportAsset()", function() {
let dir;
beforeEach(async function() {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-static-"));
await fs.writeFile(path.join(dir, "locale.abc.json"), "[]");
const provider = new lib.MemoryDatastoreProvider();
controller.modPacks = new lib.SubscribableDatastore(provider, new Map());
const modPack = lib.ModPack.fromJSON({ id: 1, name: "test" });
modPack.exportManifest = new lib.ExportManifest({
locale: "locale.abc.json", metadata: "metadata.abc.json",
});
controller.modPacks.set(modPack);
controller.app.use("/static", express.static(dir));
controller.app.use("/static", routes.missingExportAsset);
});
afterEach(async function() {
await fs.rm(dir, { recursive: true, force: true });
});
it("should serve export files that exist", async function() {
const response = await fetch(`http://localhost:${port}/static/locale.abc.json`);
assert.equal(response.status, 200);
assert(controller.modPacks.get(1).exportManifest);
});
it("should drop the export and explain when a file is missing", async function() {
const response = await fetch(`http://localhost:${port}/static/metadata.abc.json`);
assert.equal(response.status, 404);
assert.match(await response.text(), /metadata\.abc\.json is missing/);
assert.equal(controller.modPacks.get(1).exportManifest, undefined);
});
it("should pass through files not in an export", async function() {
const response = await fetch(`http://localhost:${port}/static/other.js`);
assert.equal(response.status, 404);
assert.doesNotMatch(await response.text(), /export-data/);
assert(controller.modPacks.get(1).exportManifest);
});
});
describe("stripStaticPrefix()", function() {
it("should remove a leading static/", function() {
assert.equal(routes.stripStaticPrefix("static/main.abc.js"), "main.abc.js");
@@ -262,6 +305,293 @@ describe("controller/src/routes", function() {
assert.deepEqual(data, { name: "Test Cluster" });
});
});
describe("/api/host-pairing", function() {
let endpoint, adminToken, playerToken;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/host-pairing`;
// The mock controller lacks what pairing needs, use the real implementations.
controller.hostPairing = new HostPairingManager(controller);
controller.wsServer.remoteAddr = req => req.socket.remoteAddress;
adminToken = controller.users.signUserToken(controller.users.getByName("test"));
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
});
function createHost(name) {
const host = controller.hosts.createHost(name);
return { hostId: host.id, token: controller.hosts.signHostToken(host.id) };
}
function post(urlPath, body, headers = {}) {
return fetch(`${endpoint}${urlPath}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: typeof body === "string" || body === undefined ? body : JSON.stringify(body),
});
}
async function assertResponse(response, status, body) {
assert.equal(response.status, status);
assert.equal(response.headers.get("cache-control"), "no-store");
if (body !== undefined) {
assert.deepEqual(await response.json(), body);
}
}
async function createRequest(name = "Host A") {
const response = await post("", { name });
assert.equal(response.status, 202);
return await response.json();
}
function check(headers) {
return fetch(`${endpoint}/check`, { headers });
}
function assertNothingCreated() {
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
assert.equal(controller.hosts.records.size, 0, "a host was created");
}
describe("POST /api/host-pairing", function() {
it("should create a pending request", async function() {
const response = await post("", { name: "Host A", version: "2.0.0" });
assert.equal(response.status, 202);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["code", "requestId"]);
assert.match(body.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
const details = controller.hostPairing.records.get(body.code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "2.0.0");
assert.match(details.remoteAddress, /127\.0\.0\.1|::1/);
assert.equal(controller.hosts.records.size, 0);
});
it("should trim the name and limit the version", async function() {
let { code } = await (await post("", { name: " Host A ", version: "x".repeat(100) })).json();
let details = controller.hostPairing.records.get(code);
assert.equal(details.name, "Host A");
assert.equal(details.version, "x".repeat(64));
({ code } = await (await post("", { name: "Host B", version: 5 })).json());
details = controller.hostPairing.records.get(code);
assert.equal(details.version, "");
});
it("should respond with 400 for a missing or invalid name", async function() {
const required = { error: "Host name is required" };
await assertResponse(await post("", {}), 400, required);
await assertResponse(await post("", { name: "" }), 400, required);
await assertResponse(await post("", { name: " " }), 400, required);
await assertResponse(await post("", { name: 5 }), 400, required);
await assertResponse(await post("", undefined), 400, required);
await assertResponse(await post("", "name=Host", { "Content-Type": "text/plain" }), 400, required);
await assertResponse(
await post("", { name: "x".repeat(101) }),
400, { error: "Host name must be at most 100 characters" },
);
assertNothingCreated();
});
it("should respond with 400 for malformed JSON", async function() {
controller.app.set("env", "test"); // Silence the default error handler
const response = await post("", "{\"name\":");
assert.equal(response.status, 400);
assertNothingCreated();
});
it("should respond with 413 for a body over 4kb", async function() {
controller.app.set("env", "test"); // Silence the default error handler
const response = await post("", { name: "x".repeat(5000) });
assert.equal(response.status, 413);
assertNothingCreated();
});
it("should respond with 429 and Retry-After when limited", async function() {
for (let i = 0; i < 3; i++) {
await createRequest();
}
const response = await post("", { name: "Host A" });
assert.equal(response.headers.get("retry-after"), "30");
await assertResponse(response, 429, { error: "Too many pending requests from this address" });
assert.equal(controller.hostPairing.records.size, 3);
});
it("should respond with 503 in recovery mode", async function() {
controller.recoveryMode = true;
const recovery = { error: "Controller is in recovery mode" };
await assertResponse(await post("", { name: "Host A" }), 503, recovery);
await assertResponse(
await post("", { name: "Host A" }, { "Authorization": `Bearer ${adminToken}` }), 503, recovery
);
assertNothingCreated();
});
it("should approve directly with the token of a user allowed to approve hosts", async function() {
for (const headers of [
{ "Authorization": `Bearer ${adminToken}` },
{ "Authorization": `bearer ${adminToken}` },
{ "X-Access-Token": adminToken },
]) {
const response = await post("", { name: "Host A" }, headers);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
assert.equal(body.name, "Host A");
assert.equal(controller.hosts.get(body.hostId).name, "Host A");
const payload = jwt.verify(body.token, controller.authSecret, { audience: "host" });
assert.equal(payload.host, body.hostId);
}
assert.equal(controller.hosts.records.size, 3);
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
});
it("should validate the name before approving directly", async function() {
await assertResponse(
await post("", {}, { "Authorization": `Bearer ${adminToken}` }),
400, { error: "Host name is required" },
);
assertNothingCreated();
});
it("should respond with 403 for a user not allowed to approve hosts", async function() {
await assertResponse(
await post("", { name: "Host A" }, { "Authorization": `Bearer ${playerToken}` }),
403, { error: "User player is not allowed to approve hosts" },
);
assertNothingCreated();
});
it("should respond with 401 for an invalid token without creating a request", async function() {
// The admin user's tokens are revoked after signing this one
const revokedToken = adminToken;
const user = controller.users.getOrCreateUser("test");
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
user.saveRecord();
controller.hosts.records.set(new HostRecord(5, "five", "", new Map()));
const hostToken = controller.hosts.signHostToken(5);
controller.hosts.records.delete(controller.hosts.get(5));
for (const headers of [
{ "Authorization": "Bearer invalid" },
{ "X-Access-Token": "invalid" },
{ "Authorization": `Bearer ${controller.users.signUserToken({ id: "nobody" })}` },
{ "Authorization": `Bearer ${revokedToken}` },
{ "Authorization": `Bearer ${hostToken}` },
]) {
await assertResponse(
await post("", { name: "Host A" }, headers), 401, { error: "Invalid user token" }
);
}
assertNothingCreated();
});
});
describe("POST /api/host-pairing/poll", function() {
it("should respond with 202 while pending", async function() {
const { requestId } = await createRequest();
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});
it("should hand over the result of an approved request once", async function() {
const { requestId, code } = await createRequest();
const hostId = controller.hostPairing.approve(code, "Renamed", "test");
const response = await post("/poll", { requestId });
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
const body = await response.json();
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
assert.equal(body.hostId, hostId);
assert.equal(body.name, "Renamed");
assert.equal(jwt.verify(body.token, controller.authSecret, { audience: "host" }).host, hostId);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 403 once for a rejected request", async function() {
const { requestId, code } = await createRequest();
controller.hostPairing.reject(code, "test");
await assertResponse(await post("/poll", { requestId }), 403, { error: "Request was rejected" });
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 404 for an unknown request", async function() {
const { code } = await createRequest();
const notFound = { error: "Request not found" };
await assertResponse(await post("/poll", { requestId: "invalid" }), 404, notFound);
await assertResponse(await post("/poll", { requestId: code }), 404, notFound);
});
it("should respond with 400 without a requestId in the body", async function() {
const { requestId } = await createRequest();
const required = { error: "requestId is required" };
await assertResponse(await post("/poll", {}), 400, required);
await assertResponse(await post("/poll", { requestId: 5 }), 400, required);
await assertResponse(await post(`/poll?requestId=${encodeURIComponent(requestId)}`, {}), 400, required);
});
it("should respond with 503 in recovery mode", async function() {
const { requestId } = await createRequest();
controller.recoveryMode = true;
await assertResponse(
await post("/poll", { requestId }), 503, { error: "Controller is in recovery mode" }
);
});
});
describe("POST /api/host-pairing/cancel", function() {
it("should cancel a pending request", async function() {
const { requestId, code } = await createRequest();
await assertResponse(await post("/cancel", { requestId }), 204);
assert.equal(controller.hostPairing.records.has(code), false);
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
});
it("should respond with 204 for an unknown request", async function() {
const { code } = await createRequest();
await assertResponse(await post("/cancel", { requestId: "invalid" }), 204);
await assertResponse(await post("/cancel", { requestId: code }), 204);
assert(controller.hostPairing.records.has(code), "cancelled by the code");
});
});
describe("request ids", function() {
it("should not be accepted in the URL", async function() {
const { requestId } = await createRequest();
const id = encodeURIComponent(requestId);
for (const [method, urlPath] of [
["GET", `/poll?requestId=${id}`],
["GET", `/poll/${id}`],
["POST", `/poll/${id}`],
["POST", `/${id}`],
["GET", `/${id}`],
["POST", `/cancel/${id}`],
]) {
const response = await fetch(`${endpoint}${urlPath}`, { method });
assert.equal(response.status, 404, `${method} ${urlPath}`);
}
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
});
});
describe("GET /api/host-pairing/check", function() {
it("should respond with 200 for a valid host token", async function() {
const { hostId, token } = createHost("Host A");
for (const headers of [
{ "Authorization": `Bearer ${token}` },
{ "X-Access-Token": token },
]) {
await assertResponse(await check(headers), 200, { valid: true });
}
assert(controller.hosts.has(hostId));
});
it("should respond with 200 for a host token from before the slave rename", async function() {
const { hostId } = createHost("Host A");
const token = jwt.sign({ aud: "slave", slave: hostId }, controller.authSecret);
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
});
it("should respond with 401 without a valid host token", async function() {
createHost("Host A");
assert.equal((await check({})).status, 401);
assert.equal((await check({ "Authorization": "Bearer invalid" })).status, 401);
assert.equal((await check({ "Authorization": `Bearer ${adminToken}` })).status, 401);
});
it("should respond with 401 once the host is revoked", async function() {
const { hostId, token } = createHost("Host A");
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
controller.hosts.records.delete(controller.hosts.get(hostId));
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
});
});
});
describe("/api/upload-save", function() {
let endpoint, testToken;
beforeEach(function() {
+110
View File
@@ -5,6 +5,8 @@ import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { escapeArg } from "../packages/create/escape_arg.js";
const execFileAsync = promisify(execFile);
const installer = path.resolve(import.meta.dirname, "../packages/create/create.js");
const discoveryUrl = new URL("../packages/lib/dist/node/src/load_plugin_list.js", import.meta.url).href;
@@ -123,4 +125,112 @@ describe("create installer CLI", function() {
return true;
});
});
describe("host setup", function() {
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const hostBin = path.join("node_modules", ".bin", `clusteriohost${scriptExt}`);
const controllerBin = path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`);
const hostSet = (field, value) => `${hostBin} config set ${field} ${escapeArg(String(value))}`;
const controllerUrl = "http://controller.example.com:8080/";
function run(args, env = {}) {
// Record the commands for npm and the clusterio CLIs instead of running them.
const mockExec = `
import childProcess from "node:child_process";
import fs from "node:fs";
import { PassThrough } from "node:stream";
childProcess.execFile = (command, options, callback) => {
fs.appendFileSync(process.env.CREATE_TEST_COMMANDS, JSON.stringify(command) + "\\n");
const stdout = new PassThrough();
const stderr = new PassThrough();
queueMicrotask(() => {
stdout.end();
stderr.end();
callback(null, "", "");
});
return { stdout, stderr };
};
`;
return execFileAsync(process.execPath, [
"--import", `data:text/javascript,${encodeURIComponent(mockExec)}`,
installer,
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
...args,
], {
cwd: directory,
env: { ...process.env, CREATE_TEST_COMMANDS: path.join(directory, "commands.jsonl"), ...env },
});
}
// Commands run by the installer, without the getent lookups made when writing systemd units.
async function commands() {
const content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
.filter(command => !command.startsWith("getent "));
}
function hostArgs(url = controllerUrl) {
return [
"--mode", "host", "--host-name", "Test Host", "--controller-url", url,
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm", "--plugins",
];
}
it("writes the host config and leaves joining the cluster to the host", async function() {
const { stdout } = await run(hostArgs());
assert.match(stdout, /Successfully installed host/);
const recorded = await commands();
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
assert.deepEqual(recorded.slice(1), [
`${hostBin} config create`,
hostSet("host.name", "Test Host"),
hostSet("host.controller_url", controllerUrl),
hostSet("host.public_address", "host.example.com"),
hostSet("host.factorio_directory", "factorio"),
hostSet("host.allow_remote_updates", false),
hostSet("host.allow_plugin_updates", false),
hostSet("host.allow_plugin_install", false),
]);
await fs.access(path.join(directory, `run-host${process.platform === "win32" ? ".cmd" : ".sh"}`));
});
it("adds the trailing slash the host requires to the controller URL", async function() {
await run(hostArgs(controllerUrl.slice(0, -1)));
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
});
it("leaves joining the cluster to the host of a standalone install", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
const { stdout } = await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
]);
assert.match(stdout, /Successfully installed standalone/);
const recorded = await commands();
for (const expected of [
hostSet("host.name", "local"),
hostSet("host.controller_url", "http://localhost:8123/"),
`${controllerBin} config set controller.allow_remote_updates true`,
`${controllerBin} config set controller.allow_plugin_updates true`,
`${controllerBin} config set controller.allow_plugin_install true`,
hostSet("host.allow_remote_updates", true),
hostSet("host.allow_plugin_updates", true),
hostSet("host.allow_plugin_install", true),
]) {
assert(recorded.includes(expected), `missing ${expected}`);
}
assert(!recorded.some(command => command.includes("host.controller_token")));
});
it("uses the host name given for a standalone install", async function() {
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
await run([
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
"--factorio-dir", "factorio", "--remote-npm", "--plugins", "--host-name", "Test Host",
]);
assert((await commands()).includes(hostSet("host.name", "Test Host")));
});
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

@@ -0,0 +1,3 @@
---------------------------------------------------------------------------------------------------
Version: 0.1.2
About: Dummy changelog.txt for FactorioServer tests
@@ -0,0 +1,3 @@
---------------------------------------------------------------------------------------------------
Version: 0.1.2
About: Dummy changelog.txt for FactorioServer tests
+100 -3
View File
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import path from "node:path";
import { FactorioServer } from "@clusterio/host/dist/node/src/server.js";
import { _exportLocale, _loadLayeredIcon, _loadSimpleIcon } from "@clusterio/host/dist/node/src/export.js";
import {
_checkMissingBuiltinIcons, _createSheetState, _exportIcons, _exportLocale, _loadLayeredIcon, _loadSimpleIcon,
} from "@clusterio/host/dist/node/src/export.js";
describe("host/src/export", function() {
@@ -12,7 +14,25 @@ describe("host/src/export", function() {
testServer = await FactorioServer.fromOptions(path.join("test", "file", "factorio"), writePath, {});
});
let warnings;
let originalLogger;
beforeEach(function() {
warnings = [];
originalLogger = testServer._logger;
testServer._logger = Object.create(originalLogger, { warn: { value: message => warnings.push(message) } });
});
afterEach(function() {
testServer._logger = originalLogger;
});
describe("exportLocale()", function() {
it("warns about mods without a zip and exports the rest", async function() {
const locale = await _exportLocale(
testServer, new Map([["missing_mod", "1.0.0"]]), new Map(), ["base", "missing_mod"], "en"
);
assert.equal(locale.size > 0, true);
assert.deepEqual(warnings, ["Skipping locale of missing_mod, its mod zip was not found"]);
});
it("returns a flattened mapping with locale definitions", async function() {
let locale = await _exportLocale(testServer, new Map(), new Map(), ["base"], "en");
assert.deepEqual(locale, new Map([
@@ -30,20 +50,59 @@ describe("host/src/export", function() {
// test-icon.png is a red 64px icon followed by a blue 32px mipmap
const red = 0xff0000ff;
describe("exportIcons()", function() {
it("skips icons that fail to load and exports the rest", async function() {
const state = await _createSheetState();
const icon = "__base__/graphics/icons/test-icon.png";
const prototypes = [
{ type: "item", name: "first", icon, icon_size: 64 },
{ type: "item", name: "no-layers", icons: [] },
{ type: "item", name: "unknown-mod", icon: "__not-a-mod__/graphics/icons/x.png", icon_size: 64 },
{ type: "item", name: "last", icon, icon_size: 64 },
];
const count = await _exportIcons(testServer, new Map(), { zipCache: new Map() }, prototypes, "item", state);
assert.equal(count, 2);
assert.deepEqual(
state.metadata.item.map(entry => [entry.name, Boolean(entry.icon)]),
[["first", true], ["no-layers", false], ["unknown-mod", false], ["last", true]],
);
assert.equal(warnings.length, 2);
assert.match(warnings[0], /^Skipping icon of item no-layers: /);
assert.match(warnings[1], /^Skipping icon of item unknown-mod: Got path for unknown mod not-a-mod/);
});
});
// Stands in for the data dir of a full install
const graphicsDataDir = path.join("test", "file", "factorio-mixed", "full", "data");
describe("loadSimpleIcon()", function() {
it("defaults icon_size to 64", async function() {
const icon = await _loadSimpleIcon(
testServer, new Map(), new Map(), { icon: "__base__/graphics/icons/test-icon.png" }, 32, new Map()
testServer, new Map(), { zipCache: new Map() },
{ icon: "__base__/graphics/icons/test-icon.png" }, 32, new Map(),
);
assert.equal(icon.bitmap.width, 32);
assert.equal(icon.bitmap.height, 32);
assert.equal(icon.getPixelColor(31, 16), red);
});
it("returns null when the icon file is missing", async function() {
const icon = await _loadSimpleIcon(
testServer, new Map(), { zipCache: new Map() },
{ icon: "__base__/graphics/icons/graphics-icon.png" }, 32, new Map(),
);
assert.equal(icon, null);
});
it("falls back to graphicsDataDir for builtin mod files", async function() {
const icon = await _loadSimpleIcon(
testServer, new Map(), { zipCache: new Map(), graphicsDataDir },
{ icon: "__base__/graphics/icons/graphics-icon.png" }, 32, new Map(),
);
assert.equal(icon.getPixelColor(31, 16), red);
});
});
describe("loadLayeredIcon()", function() {
it("defaults icon_size to 64", async function() {
const icon = await _loadLayeredIcon(
testServer, new Map(), new Map(),
testServer, new Map(), { zipCache: new Map() },
{ icons: [{ icon: "__base__/graphics/icons/test-icon.png" }] }, 32, new Map(),
);
assert.equal(icon.bitmap.width, 32);
@@ -51,5 +110,43 @@ describe("host/src/export", function() {
assert.equal(icon.getPixelColor(0, 0), red);
assert.equal(icon.getPixelColor(31, 31), red);
});
it("returns null when a layer is missing", async function() {
const icon = await _loadLayeredIcon(
testServer, new Map(), { zipCache: new Map() },
{ icons: [
{ icon: "__base__/graphics/icons/test-icon.png" },
{ icon: "__base__/graphics/icons/missing.png" },
] }, 32, new Map(),
);
assert.equal(icon, null);
});
});
describe("checkMissingBuiltinIcons()", function() {
it("warns once with the number of missing builtin files", function() {
_checkMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([
["__base__/graphics/icons/a.png", null],
["__core__/graphics/icons/b.png", null],
["__base__/graphics/icons/c.png", {}],
["__some_mod__/graphics/icons/d.png", null],
]));
assert.equal(warnings.length, 1);
assert.match(warnings[0], /^2 icon files of the base game were not found/);
assert.doesNotMatch(warnings[0], /headless/);
});
it("explains the headless build when no full install was found", function() {
_checkMissingBuiltinIcons(testServer, { zipCache: new Map(), graphicsError: "Not found" }, new Map([
["__base__/graphics/icons/a.png", null],
]));
assert.equal(warnings.length, 1);
assert.match(warnings[0], /has no graphics and no full install was found \(Not found\)/);
assert.match(warnings[0], /Add a full install of Factorio 0\.1 to host\.factorio_directory/);
});
it("does not warn when no builtin files are missing", function() {
_checkMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([
["__base__/graphics/icons/c.png", {}],
["__some_mod__/graphics/icons/d.png", null],
]));
assert.deepEqual(warnings, []);
});
});
});
+334
View File
@@ -0,0 +1,334 @@
import assert from "node:assert/strict";
import events from "node:events";
import fs from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { PairingError, checkHostToken, pairHost } from "@clusterio/host";
import { ensurePaired } from "@clusterio/host/dist/node/src/pairing.js";
import "../setup_logging.js";
// Stub controller recording requests, responding with what the test's handler returns.
async function startStub() {
const stub = {
requests: [],
handler: () => [500, { error: "No handler" }],
};
stub.server = http.createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const entry = {
method: req.method,
url: req.url,
headers: req.headers,
body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : undefined,
};
stub.requests.push(entry);
const [status, json, headers = {}] = await stub.handler(entry);
res.writeHead(status, { "Content-Type": "application/json", ...headers });
res.end(json === undefined ? undefined : JSON.stringify(json));
});
stub.server.listen(0, "127.0.0.1");
await events.once(stub.server, "listening");
stub.url = `http://127.0.0.1:${stub.server.address().port}/`;
stub.close = async () => {
stub.server.closeAllConnections();
stub.server.close();
await events.once(stub.server, "close");
};
return stub;
}
const approved = { hostId: 1234, token: "host-token", name: "Approved" };
// Handler for requests that are approved after the given number of pending polls.
function approveAfter(polls, result = approved) {
let pending = 0;
let requests = 0;
return ({ url }) => {
if (url === "/api/host-pairing") {
requests += 1;
return [202, { requestId: `secret-${requests}`, code: `ABC-DE${requests}` }];
}
if (url === "/api/host-pairing/poll") {
pending += 1;
return pending > polls ? [200, result] : [202, { status: "pending" }];
}
if (url === "/api/host-pairing/cancel") {
return [204, undefined];
}
return [404, { error: "Not found" }];
};
}
describe("host/src/pairing", function() {
let stub;
beforeEach(async function() {
stub = await startStub();
});
afterEach(async function() {
await stub.close();
});
function pair(options = {}) {
return pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10, retryDelayMs: 10, ...options });
}
describe("pairHost()", function() {
it("returns the host when approved with a user token", async function() {
stub.handler = ({ headers }) => (headers.authorization === "Bearer user-token"
? [200, approved]
: [401, { error: "Invalid user token" }]
);
const result = await pair({ token: "user-token", onPending: () => assert.fail("should not wait") });
assert.deepEqual(result, approved);
assert.equal(stub.requests.length, 1);
});
it("polls until the request is approved", async function() {
stub.handler = approveAfter(2);
const codes = [];
const result = await pair({ version: "2.1.0", onPending: code => { codes.push(code); } });
assert.deepEqual(result, approved);
assert.deepEqual(codes, ["ABC-DE1"]);
assert.deepEqual(stub.requests[0].body, { name: "Host", version: "2.1.0" });
assert.equal(stub.requests[0].headers.authorization, undefined);
for (const poll of stub.requests.slice(1)) {
assert.deepEqual(poll.body, { requestId: "secret-1" });
}
});
it("resolves routes under the path of the controller URL", async function() {
stub.handler = ({ url }) => (url === "/sub/api/host-pairing" ? [200, approved] : [404, {}]);
const result = await pair({ controllerUrl: `${stub.url}sub/` });
assert.equal(result.hostId, 1234);
});
it("makes a new request when the previous one expired", async function() {
const handler = approveAfter(0);
let expired = false;
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll" && !expired) {
expired = true;
return [404, { error: "Request not found" }];
}
return handler(entry);
};
const codes = [];
const result = await pair({ onPending: code => { codes.push(code); } });
assert.deepEqual(result, approved);
assert.deepEqual(codes, ["ABC-DE1", "ABC-DE2"]);
});
it("waits and tries again when the controller is busy", async function() {
const handler = approveAfter(0);
let busy = 2;
stub.handler = entry => {
if (entry.url === "/api/host-pairing" && busy > 0) {
busy -= 1;
return busy ? [429, { error: "Too many requests" }] : [503, { error: "Recovery mode" }];
}
return handler(entry);
};
const retries = [];
const result = await pair({ onRetry: err => { retries.push(err.message); } });
assert.deepEqual(result, approved);
assert.deepEqual(retries, ["Too many requests", "Recovery mode"]);
});
it("keeps trying while the controller can't be reached", async function() {
const { url } = stub;
await stub.close();
const abort = new AbortController();
const errors = [];
await assert.rejects(
pairHost({
controllerUrl: url, name: "Host", retryDelayMs: 10, signal: abort.signal,
onRetry: err => {
errors.push(err);
if (errors.length === 3) {
abort.abort();
}
},
}),
{ name: "AbortError" },
);
assert.equal(errors.length, 3);
stub = await startStub();
});
it("keeps polling while the controller is busy", async function() {
const handler = approveAfter(0);
let busy = 2;
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll" && busy > 0) {
busy -= 1;
return [503, { error: "Recovery mode" }];
}
return handler(entry);
};
const retries = [];
const result = await pair({ onRetry: err => { retries.push(err.message); } });
assert.deepEqual(result, approved);
assert.deepEqual(retries, ["Recovery mode", "Recovery mode"]);
});
it("throws when the request is rejected", async function() {
const handler = approveAfter(Infinity);
stub.handler = entry => (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2
? [403, { error: "Request was rejected" }]
: handler(entry)
);
await assert.rejects(
pair(),
err => err instanceof PairingError && err.status === 403 && err.message === "Request was rejected",
);
});
it("throws if the controller does not support pairing", async function() {
stub.handler = () => [404, undefined];
await assert.rejects(
pair(),
err => err instanceof PairingError && err.status === 404 && /must be updated/.test(err.message),
);
});
it("throws on an invalid user token without waiting", async function() {
stub.handler = () => [401, { error: "Invalid user token" }];
await assert.rejects(
pair({ token: "bad" }),
err => err instanceof PairingError && err.status === 401 && err.message === "Invalid user token",
);
assert.equal(stub.requests.length, 1);
});
it("cancels the request when aborted", async function() {
const abort = new AbortController();
const handler = approveAfter(Infinity);
stub.handler = entry => {
if (entry.url === "/api/host-pairing/poll") {
abort.abort();
}
return handler(entry);
};
await assert.rejects(pair({ signal: abort.signal }), { name: "AbortError" });
const cancel = stub.requests.find(r => r.url === "/api/host-pairing/cancel");
assert.deepEqual(cancel?.body, { requestId: "secret-1" });
});
it("does not send anything if already aborted", async function() {
await assert.rejects(pair({ signal: AbortSignal.abort() }), { name: "AbortError" });
assert.equal(stub.requests.length, 0);
});
});
describe("checkHostToken()", function() {
it("returns true if the controller accepts the token", async function() {
stub.handler = ({ url, headers }) => (
url === "/api/host-pairing/check" && headers.authorization === "Bearer host-token"
? [200, { valid: true }]
: [401, undefined]
);
assert.equal(await checkHostToken(stub.url, "host-token"), true);
assert.equal(await checkHostToken(stub.url, "other-token"), false);
});
it("throws on other responses", async function() {
stub.handler = () => [404, undefined];
await assert.rejects(checkHostToken(stub.url, "host-token"), PairingError);
});
});
describe("ensurePaired()", function() {
let directory;
let config;
beforeEach(async function() {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-pairing-"));
config = new lib.HostConfig("host", undefined, path.join(directory, "config-host.json"));
config.set("host.name", "My Host");
config.set("host.controller_url", stub.url);
});
afterEach(async function() {
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
});
async function savedConfig() {
return JSON.parse(await fs.readFile(path.join(directory, "config-host.json"), "utf8"));
}
it("waits for approval and saves the host when not paired", async function() {
this.timeout(5000); // Polls at the default interval of 2 seconds
stub.handler = approveAfter(0);
const warnings = [];
const originalWarn = lib.logger.warn;
lib.logger.warn = message => { warnings.push(message); };
try {
await ensurePaired(config, { version: "2.1.0" });
} finally {
lib.logger.warn = originalWarn;
}
assert.deepEqual(stub.requests[0].body, { name: "My Host", version: "2.1.0" });
// The systemd service created by the installer only prints warnings and above.
assert(warnings.includes(`${stub.url}hosts?approve=ABC-DE1`), "approval link is not a warning");
const saved = await savedConfig();
assert.equal(saved["host.id"], 1234);
assert.equal(saved["host.name"], "Approved");
assert.equal(saved["host.controller_token"], "host-token");
});
it("does nothing when the controller accepts the saved token", async function() {
config.set("host.controller_token", "valid-token");
stub.handler = ({ headers }) => (headers.authorization === "Bearer valid-token"
? [200, { valid: true }]
: [401, undefined]
);
await ensurePaired(config, { version: "2.1.0" });
assert.deepEqual(stub.requests.map(r => r.url), ["/api/host-pairing/check"]);
assert.equal(config.get("host.controller_token"), "valid-token");
});
it("asks to join again with the user token when the saved token is not accepted", async function() {
config.set("host.controller_token", "revoked-token");
stub.handler = ({ url, headers }) => {
if (url === "/api/host-pairing/check") {
return [401, undefined];
}
return headers.authorization === "Bearer user-token"
? [200, { hostId: 5, token: "new-token", name: "My Host" }]
: [401, { error: "Invalid user token" }];
};
await ensurePaired(config, { version: "2.1.0", userToken: "user-token" });
const saved = await savedConfig();
assert.equal(saved["host.id"], 5);
assert.equal(saved["host.controller_token"], "new-token");
assert(!JSON.stringify(saved).includes("user-token"), "user token must not be saved");
});
it("stops when aborted while checking the saved token", async function() {
config.set("host.controller_token", "some-token");
const abort = new AbortController();
stub.handler = () => {
abort.abort();
return new Promise(() => {});
};
await assert.rejects(
ensurePaired(config, { version: "2.1.0", signal: abort.signal }),
{ name: "AbortError" },
);
assert.equal(config.get("host.controller_token"), "some-token");
});
it("keeps the saved token when it can't be checked", async function() {
config.set("host.controller_token", "some-token");
stub.handler = () => [404, undefined];
await ensurePaired(config, { version: "2.1.0" });
assert.equal(config.get("host.controller_token"), "some-token");
assert.equal(stub.requests.length, 1);
});
});
});
+88
View File
@@ -271,4 +271,92 @@ describe("host/patch", function() {
);
});
});
describe("unpatch()", function() {
const freeplayControl = "require('__base__/script/freeplay/control.lua')\n";
const zipPath = path.join("temp", "test", "unpatch.zip");
let testModule;
before(async function() {
testModule = new patch.SaveModule(new lib.ModuleInfo("test", "1.0.0"));
await testModule.loadFiles("test/file/modules/test");
});
async function readSave() {
const zip = await lib.ZipArchive.fromFile(zipPath);
try {
const files = new Map();
for (const name of zip.entries.keys()) {
files.set(name, (await zip.readFile(name)).toString("utf8"));
}
return files;
} finally {
zip.close();
}
}
it("should leave saves that are not patched untouched", async function() {
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]]));
assert.equal(await patch.unpatch(zipPath), false);
assert.deepEqual([...(await readSave()).keys()], ["world/control.lua"]);
});
it("should restore control.lua and remove module files", async function() {
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", freeplayControl],
["world/level.dat0", "level"],
]));
await patch.patch(zipPath, [testModule]);
assert.equal(await patch.unpatch(zipPath), true);
const files = await readSave();
assert.deepEqual(
[...files.keys()].sort(),
["world/clusterio.json", "world/control.lua", "world/level.dat0"],
);
assert.equal(files.get("world/control.lua"), freeplayControl);
const patchInfo = JSON.parse(files.get("world/clusterio.json"));
assert.equal(patchInfo.patch_number, 1);
assert.deepEqual(patchInfo.modules, []);
assert.equal(await patch.unpatch(zipPath), false, "unpatched twice");
});
it("should throw on a newer patch version", async function() {
const scenario = new lib.ModuleInfo("freeplay", "2.0.29", [], ["scenario"]);
const patchInfo = { version: 2, patch_number: 1, scenario, modules: [] };
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", freeplayControl],
["world/clusterio.json", JSON.stringify(patchInfo)],
]));
await assert.rejects(patch.unpatch(zipPath), /Save patch version 2 is newer/);
await assert.rejects(patch.patch(zipPath, []), /Save patch version 2 is newer/);
});
it("should keep counting the patch number when patched again", async function() {
await fs.writeFile(zipPath, await createZipBuffer([["world/control.lua", freeplayControl]]));
await patch.patch(zipPath, [testModule]);
await patch.unpatch(zipPath);
await patch.patch(zipPath, [testModule]);
const files = await readSave();
assert.equal(JSON.parse(files.get("world/clusterio.json")).patch_number, 2);
assert(files.has("world/modules/test/test.lua"));
assert.equal(files.get("world/scenario.lua"), freeplayControl);
assert.match(files.get("world/control.lua"), /^-- Auto generated scenario module loader/);
});
it("should keep the loader of scenarios made for Clusterio", async function() {
const scenario = new lib.ModuleInfo("custom", "1.0.0", [], ["custom"]);
const patchInfo = new patch.PatchInfo(3, scenario, [testModule]);
await fs.writeFile(zipPath, await createZipBuffer([
["world/control.lua", patch._generateLoader(patchInfo)],
["world/clusterio.json", JSON.stringify(patchInfo)],
["world/custom.lua", "-- custom\n"],
...[...testModule.files].map(([name, content]) => [`world/${name}`, content]),
]));
assert.equal(await patch.unpatch(zipPath), true);
const files = await readSave();
assert.deepEqual(
[...files.keys()].sort(),
["world/clusterio.json", "world/control.lua", "world/custom.lua"],
);
assert.equal(files.get("world/control.lua"), patch._generateLoader(new patch.PatchInfo(3, scenario, [])));
});
});
});
+32
View File
@@ -105,6 +105,38 @@ describe("host/server", function() {
);
});
});
describe("headless argument", function() {
const mixedDir = path.join("test", "file", "factorio-mixed");
it("should find only full installs when false", async function() {
const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", false);
assert.equal(dir, path.join(mixedDir, "full", "data"));
});
it("should find only headless installs when true", async function() {
const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", true);
assert.equal(dir, path.join(mixedDir, "headless", "data"));
});
it("should skip installs of the other build when matching a partial version", async function() {
const installDir = path.join("test", "file", "factorio");
const [dir, version] = await hostServer.findVersion(installDir, "0.1", false);
assert.deepEqual([dir, version], [path.join(installDir, "0.1.2", "data"), "0.1.2"]);
});
it("should reject a direct install of the other build", async function() {
const installDir = path.join(mixedDir, "headless");
await assert.rejects(
hostServer.findVersion(installDir, "0.1.2", false),
new Error(
`Unable to find a full install of Factorio 0.1.2: ${installDir} is a direct ` +
"(single-version) install of the headless build."
)
);
});
it("should reject if no install of that build has the version", async function() {
await assert.rejects(
hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", true),
new Error("Unable to find headless Factorio version 0.1.2")
);
});
});
});
describe("_listFactorioVersions()", function() {
+358 -64
View File
@@ -10,11 +10,12 @@ import * as libBuildMod from "@clusterio/lib/build_mod.js";
import * as testStrings from "../lib/factorio/test_strings.js";
import {
TestControl, TestControlConnector, url, controlToken,
TestControl, TestControlConnector, TestHostConnector, url, controlToken, hostId,
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
requiresFactorio, hasFactorio,
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
} from "./index.js";
import { pairHost, checkHostToken, PairingError } from "@clusterio/host";
import { externalTest, slowTest } from "../common.js";
import { getTestRconPort } from "./rcon_port.js";
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
@@ -31,6 +32,18 @@ async function checkInstanceStatus(id, status) {
assert.equal(instances.get(44).status, status, "incorrect instance status");
}
// Factorio directory relative to temp/test where the hosts are run from
const hostFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
/** Id, token and name of the alt-host, paired the first time it starts */
const altHost = { hostId: undefined, token: undefined, name: undefined };
async function pairAltHost() {
if (altHost.hostId === undefined) {
Object.assign(altHost, await pairTestHost("alt-host"));
}
}
async function spawnAltHost(config = "alt-host-config.json") {
return await spawnNode("alt-host:", `../../packages/host run --config ${config}`, /Started host/);
}
@@ -41,10 +54,10 @@ async function startAltHost() {
await fs.rm(configPath, { force: true });
await fs.rm(path.join("temp", "test", "alt-instances"), { force: true, recursive: true, maxRetries: 10 });
await fs.rm(path.join("temp", "test", "alt-mods"), { force: true, recursive: true, maxRetries: 10 });
await execCtl(`host create-config --id 5 --name alt-host --generate-token --output ${config}`);
await pairAltHost();
await writeHostConfig(configPath, altHost);
await execHost(`--config ${config} config set host.instances_directory alt-instances`);
const dir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await execHost(`--config ${config} config set host.factorio_directory ${dir}`);
await execHost(`--config ${config} config set host.factorio_directory ${hostFactorioDir}`);
await execHost(`--config ${config} config set host.mods_directory alt-mods`);
return await spawnAltHost(config);
}
@@ -57,7 +70,7 @@ async function stopAltHost(hostProcess) {
"timeout"
).catch(() => {});
try {
await execCtl("host stop 5");
await execCtl(`host stop ${altHost.hostId}`);
} catch (err) {
// Some tests cause the host to stop
}
@@ -105,6 +118,28 @@ function jsonArg(value) {
return `"${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}"`;
}
/** Run a ctl command in this process and return what it printed */
async function execCtlOutput(command) {
const lines = [];
const originalLog = console.log; // eslint-disable-line no-console
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
try {
await execCtl(command);
} finally {
console.log = originalLog; // eslint-disable-line no-console
}
return lines.join("\n");
}
/** Validator for assert.rejects expecting a PairingError with the given HTTP status */
function pairingError(status) {
return err => {
assert(err instanceof PairingError, `Expected a PairingError, got ${err.stack}`);
assert.equal(err.status, status, err.message);
return true;
};
}
describe("Integration of Clusterio", function() {
describe("clusteriocontroller", function() {
describe("bootstrap generate-user-token", function() {
@@ -281,11 +316,15 @@ describe("Integration of Clusterio", function() {
describe("hostUpdateEventHandler()", function() {
it("should trigger when a new host is added", async function() {
slowTest(this);
// Pairing sends an update for the new host before it connects, which is not what is tested here.
// The round trip makes sure that update has been received before clearing the updates.
await pairAltHost();
await getControl().send(new lib.HostListRequest());
getControl().hostUpdates = [];
await runWithAltHost(async () => {
await execCtl("instance create alt-test --id 99");
await execCtl("instance assign alt-test 5");
await execCtl(`instance assign alt-test ${altHost.hostId}`);
});
let sawUpdate = false;
@@ -311,7 +350,7 @@ describe("Integration of Clusterio", function() {
let result = await getControl().send(new lib.HostListRequest());
let hosts = new Map(result.map(instance => [instance.id, instance]));
assert(hosts.has(5), "Host list was not updated");
assert(hosts.has(altHost.hostId), "Host list was not updated");
});
});
it("should download mods from controller", async function() {
@@ -325,7 +364,7 @@ describe("Integration of Clusterio", function() {
}
assert(!await checkModDownloaded(), "mod was downloaded before the test");
await execCtl("instance create alt-mod --id 98");
await execCtl("instance assign alt-mod 5");
await execCtl(`instance assign alt-mod ${altHost.hostId}`);
await execCtl("instance save create alt-mod");
assert(await checkModDownloaded(), "mod was not downloaded by the test");
});
@@ -340,7 +379,7 @@ describe("Integration of Clusterio", function() {
hostProcess = await startAltHost();
await execCtl("instance create alt-start --id 97");
await execCtl(`instance config set alt-start factorio.rcon_port ${await getTestRconPort()}`);
await execCtl("instance assign alt-start 5");
await execCtl(`instance assign alt-start ${altHost.hostId}`);
await execCtl("instance config set alt-start instance.auto_start true");
const visibility = jsonArg({ lan: false, public: false });
await execCtlProcess(`instance config set-prop alt-start factorio.settings visibility ${visibility}`);
@@ -447,7 +486,7 @@ describe("Integration of Clusterio", function() {
});
it("should load the plugin on the host", async function() {
const details = await pluginDetails({ hostId: 4 });
const details = await pluginDetails({ hostId });
assert.equal(details.state, "loaded", "test_plugin is not loaded on the host");
});
@@ -468,7 +507,7 @@ describe("Integration of Clusterio", function() {
});
it("should route a plugin request from control to a host", async function() {
const received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("never-broadcast"));
const received = await getControl().sendTo({ hostId }, new HostEchoReceived("never-broadcast"));
assert.equal(received, false);
});
@@ -478,7 +517,7 @@ describe("Integration of Clusterio", function() {
// The broadcast is sent without waiting for the hosts to receive it.
let received = false;
for (let attempt = 0; attempt < 100 && !received; attempt++) {
received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("to-hosts"));
received = await getControl().sendTo({ hostId }, new HostEchoReceived("to-hosts"));
if (!received) {
await lib.wait(20);
}
@@ -591,31 +630,31 @@ describe("Integration of Clusterio", function() {
describe("host config", function() {
it("changes host config", async function() {
await execCtl("host config set 4 host.name My-Host");
const result = await execCtlProcess("host config list 4");
await execCtl(`host config set ${hostId} host.name My-Host`);
const result = await execCtlProcess(`host config list ${hostId}`);
assert(/My-Host/.test(result.stdout), "New host name not in config output");
});
it("should not allow setting host.controller_token", async function() {
await assert.rejects(
execCtl("host config set 4 host.controller_token xyz"),
execCtl(`host config set ${hostId} host.controller_token xyz`),
/Field 'host\.controller_token' is not accessible/,
);
});
it("should not allow setting host.id", async function() {
await assert.rejects(
execCtl("host config set 4 host.id 200"),
execCtl(`host config set ${hostId} host.id 200`),
/Setting 'host\.id' while host is running is not supported/,
);
});
it("should not leak host.controller_token", async function() {
let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest());
let result = await getControl().sendTo({ hostId }, new lib.HostConfigGetRequest());
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
});
});
describe("host plugin list", function() {
it("runs", async function() {
await execCtl("host plugin list 4");
await execCtl(`host plugin list ${hostId}`);
});
});
@@ -623,7 +662,7 @@ describe("Integration of Clusterio", function() {
it("runs", async function() {
// In dev plugins have no npm package, so best we can do is get an error from the host
await assert.rejects(
execCtl("host plugin update 4 foo"),
execCtl(`host plugin update ${hostId} foo`),
/Plugin foo is not installed on this machine/
);
});
@@ -635,7 +674,7 @@ describe("Integration of Clusterio", function() {
// Default is to disallow updates, changing this value would require a restart
// Additionally, it can not be changed via ctl, so best we can do is get an error from the host
await assert.rejects(
execCtl("host plugin install 4 foo"),
execCtl(`host plugin install ${hostId} foo`),
/Plugin installs are disabled on this machine/
);
});
@@ -644,15 +683,15 @@ describe("Integration of Clusterio", function() {
describe("host update", function() {
it("runs", async function() {
await execCtl("host update 4");
await execCtl(`host update ${hostId}`);
});
it("accepts --all", async function() {
await execCtl("host update 4 --all");
await execCtl(`host update ${hostId} --all`);
});
it("accepts --restart", async function() {
// We cannot restart the host, so we check for host error instead
await assert.rejects(
execCtl("host update 4 --restart"),
execCtl(`host update ${hostId} --restart`),
/Cannot restart, host does not have a process monitor to restart it./
);
});
@@ -662,7 +701,7 @@ describe("Integration of Clusterio", function() {
it("runs", async function() {
// We cannot restart the host, so we check for host error instead
await assert.rejects(
execCtl("host restart 4"),
execCtl(`host restart ${hostId}`),
/Cannot restart, host does not have a process monitor to restart it./
);
});
@@ -670,41 +709,226 @@ describe("Integration of Clusterio", function() {
describe("host mark-restart-required", function() {
it("runs", async function() {
await execCtl("host mark-restart-required 4");
await execCtl(`host mark-restart-required ${hostId}`);
});
});
describe("host generate-token", function() {
it("runs", async function() {
await execCtl("host generate-token --id 42");
describe("host pending, approve and reject", function() {
let pairings = [];
afterEach(async function() {
// Stop hosts left waiting by a failed test
for (const pairing of pairings) {
pairing.abort.abort();
}
await Promise.allSettled(pairings.map(pairing => pairing.result));
pairings = [];
});
it("runs without an id", async function() {
await execCtl("host generate-token");
});
});
describe("host revoke-token", async function() {
it("should disconnect existing host", async function() {
slowTest(this);
let sawDisconnected;
await runWithAltHost(async () => {
const control = getControl();
control.hostUpdates = [];
await wait(50); // Allow for host to update instances
await execCtl("host revoke-token 5");
await wait(50); // Allow controller to notify control
for (let update of control.hostUpdates) {
if (update.name !== "alt-host") {
continue;
}
if (!update.connected) {
sawDisconnected = true;
}
}
// Start pairing like a host without a user token, returns once the request is pending.
async function requestPairing(name) {
const abort = new AbortController();
let onPending;
const pending = new Promise(resolve => { onPending = resolve; });
const result = pairHost({
controllerUrl: url, name, pollIntervalMs: 100, signal: abort.signal, onPending,
});
const pairing = { abort, result, code: undefined };
pairings.push(pairing);
const ended = result.then(() => {
throw new Error("Pairing finished before the request was pending");
});
ended.catch(() => {}); // Only of interest while racing the pending request
pairing.code = await Promise.race([pending, ended]);
return pairing;
}
assert(sawDisconnected, "No host update with status disconnected was sent after revoking token");
it("should list a waiting request and hand the host over when approved", async function() {
const control = getControl();
control.hostPairingUpdates = [];
const pairing = await requestPairing("pending-host");
assert.match(pairing.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
const pendingOutput = await execCtlOutput("host pending");
const pendingLines = pendingOutput.split("\n");
assert(
pendingLines.some(line => line.includes(pairing.code) && line.includes("pending-host")),
`Request missing from host pending output:\n${pendingOutput}`,
);
assert(
control.hostPairingUpdates.some(update => update.code === pairing.code && !update.isDeleted),
"No pairing update was sent for the new request",
);
// Codes are matched ignoring case and separators
const typedCode = pairing.code.replace("-", "").toLowerCase();
const approveOutput = await execCtlOutput(`host approve ${typedCode} --name approved-host`);
const approved = await pairing.result;
assert.equal(approved.name, "approved-host");
assert(approveOutput.includes(String(approved.hostId)), `Host id missing from: ${approveOutput}`);
assert(
control.hostPairingUpdates.some(update => update.code === pairing.code && update.isDeleted),
"No pairing update was sent for the approved request",
);
const requests = await control.send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.code === pairing.code), "Approved request is still pending");
const hosts = await control.send(new lib.HostListRequest());
assert.equal(hosts.find(host => host.id === approved.hostId)?.name, "approved-host");
assert.equal(await checkHostToken(url, approved.token), true);
await execCtl(`host revoke ${approved.hostId}`);
});
it("should make the host fail when the request is rejected", async function() {
const pairing = await requestPairing("rejected-host");
await execCtl(`host reject ${pairing.code}`);
await assert.rejects(pairing.result, pairingError(403));
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.code === pairing.code), "Rejected request is still pending");
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
});
it("should remove the request when the host stops waiting", async function() {
const pairing = await requestPairing("cancelled-host");
pairing.abort.abort();
await assert.rejects(pairing.result, err => err.name === "AbortError");
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.code === pairing.code), "Cancelled request is still pending");
});
it("should fail for a code that is not pending", async function() {
await assert.rejects(execCtl("host approve 222-222"), /No pending host request with code 222-222/);
await assert.rejects(execCtl("host reject 222-222"), /No pending host request with code 222-222/);
});
it("should refuse an invalid user token instead of waiting for approval", async function() {
await assert.rejects(
pairHost({ controllerUrl: url, name: "invalid-token-host", token: "invalid" }), pairingError(401)
);
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.name === "invalid-token-host"), "Request was created");
});
it("should refuse a user token without permission to approve hosts", async function() {
await getControl().send(new lib.UserCreateRequest("pairing-player"));
try {
const token = jwt.sign(
{ aud: "user", user: "pairing-player" }, Buffer.from("TestSecretDoNotUse", "base64")
);
await assert.rejects(
pairHost({ controllerUrl: url, name: "unapproved-host", token }), pairingError(403)
);
} finally {
await getControl().send(new lib.UserDeleteRequest("pairing-player"));
}
const requests = await getControl().send(new lib.HostPairingListRequest());
assert(!requests.some(request => request.name === "unapproved-host"), "Request was created");
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.name === "unapproved-host"), "Host was added");
});
});
describe("host revoke", function() {
it("should remove the host and invalidate its token", async function() {
const pairing = await pairTestHost("revokee-host");
assert.equal(await checkHostToken(url, pairing.token), true);
await execCtl(`note set host ${pairing.hostId} short-lived`);
const control = getControl();
control.hostUpdates = [];
await execCtl(`host revoke ${pairing.hostId}`);
assert(
control.hostUpdates.some(update => update.id === pairing.hostId && update.isDeleted),
"No host update with isDeleted was sent",
);
const hosts = await control.send(new lib.HostListRequest());
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
assert.equal(await checkHostToken(url, pairing.token), false);
const notes = await control.send(new lib.NoteListRequest());
assert(!notes.some(note => note.id === `host/${pairing.hostId}`), "Note of the host was not removed");
});
it("should unassign the instances of the host", async function() {
const pairing = await pairTestHost("assigned-host");
// Instances can only be assigned to connected hosts, a link that accepts the assignment is enough.
const connector = new TestHostConnector(url, 0.2);
connector.token = pairing.token;
connector.hostId = pairing.hostId;
const link = new lib.Link(connector);
link.handle(lib.InstanceAssignInternalRequest, async () => {});
await connector.connect();
await execCtl("instance create revoke-assigned --id 45");
try {
await execCtl(`instance assign revoke-assigned ${pairing.hostId}`);
connector.setClosing(); // Stops it from reconnecting after being revoked
await execCtl(`host revoke ${pairing.hostId}`);
const hosts = await getControl().send(new lib.HostListRequest());
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
const instance = await getControl().send(new lib.InstanceDetailsGetRequest(45));
assert.equal(instance.assignedHost, undefined);
assert.equal(instance.status, "unassigned");
} finally {
await execCtl("instance delete revoke-assigned");
}
});
it("should stop a running host and have it ask to join again when started", async function() {
slowTest(this);
this.timeout(30000);
const config = "revoke-host-config.json";
const pairing = await pairTestHost("running-revokee");
await writeHostConfig(path.join("temp", "test", config), pairing, {
"host.factorio_directory": hostFactorioDir,
"host.instances_directory": "revoke-instances",
"host.mods_directory": "revoke-mods",
});
const hostProcess = await spawnNode(
"revoke-host:", `../../packages/host run --config ${config}`, /Started host/
);
try {
const exited = events.once(hostProcess, "exit");
await execCtl(`host revoke ${pairing.hostId}`);
assert.notEqual(
await lib.timeout(exited, 10e3, "timeout"), "timeout", "Host did not stop after being revoked"
);
} finally {
if (hostProcess.exitCode === null && hostProcess.signalCode === null) {
hostProcess.kill("SIGINT");
}
}
// Starting it again asks to join the cluster again, and it starts up once approved
const rejoining = await spawnNode(
"revoke-host:", `../../packages/host run --config ${config}`, /Waiting for approval/
);
const exited = events.once(rejoining, "exit");
try {
const started = new Promise(resolve => {
rejoining.stdout.on("data", chunk => {
if (String(chunk).includes("Started host")) {
resolve();
}
});
});
const requests = await getControl().send(new lib.HostPairingListRequest());
const request = requests.find(entry => entry.name === "running-revokee");
assert(request, "Revoked host did not ask to join again");
const newHostId = await getControl().send(new lib.HostPairingApproveRequest(request.code));
assert.notEqual(newHostId, pairing.hostId);
assert.notEqual(
await lib.timeout(started, 10e3, "timeout"), "timeout", "Host did not start once approved"
);
await execCtl(`host revoke ${newHostId}`);
await lib.timeout(exited, 10e3, "timeout");
} finally {
if (rejoining.exitCode === null && rejoining.signalCode === null) {
rejoining.kill("SIGINT");
}
}
});
});
@@ -747,9 +971,79 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance template inheritance", function() {
after(async function() {
for (const id of [4403, 4402, 4401, 4400]) {
await getControl().send(new lib.InstanceDeleteRequest(id)).catch(() => {});
}
});
it("creates a template and an instance inheriting from it", async function() {
await execCtl("instance create inheritTemplate --id 4400 --as-template");
await execCtl("instance config set inheritTemplate factorio.version 2.0");
await execCtl("instance create inheritChild --id 4401 --template inheritTemplate");
const config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["instance.template_id"], 4400);
assert.equal(config["factorio.version"], "2.0");
assert.equal(config["instance.inherited_fields"]["factorio.version"], true);
assert.equal((await getInstances()).get(4400).isTemplate, true);
});
it("propagates template changes per property", async function() {
await execCtl("instance config set inheritTemplate factorio.version 2.1");
await execCtl("instance config set-prop inheritChild factorio.settings max_players 3");
await execCtl("instance config set-prop inheritTemplate factorio.settings max_players 7");
await execCtl("instance config set-prop inheritTemplate factorio.settings afk_autokick_interval 9");
const config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["factorio.version"], "2.1");
assert.equal(config["factorio.settings"].max_players, 3);
assert.equal(config["factorio.settings"].afk_autokick_interval, 9);
});
it("creates a template from an existing instance", async function() {
await execCtl("instance create inheritCopy --id 4403 --as-template --from 4401");
const config = await getControl().send(new lib.InstanceConfigGetRequest(4403));
assert.equal(config["instance.is_template"], true);
assert.equal(config["factorio.settings"].max_players, 3);
});
it("rejects inheriting from an instance that is not a template", async function() {
await assert.rejects(
execCtl("instance create inheritBad --id 4402 --template inheritChild"),
new lib.ValidationError("Instance inheritChild is not a template"),
);
});
it("rejects assigning a template to a host", async function() {
await assert.rejects(
execCtl(`instance assign inheritTemplate ${hostId}`),
new lib.ValidationError("Templates cannot be assigned to a host"),
);
});
describe("instance list --templates", function() {
it("runs", async function() {
await execCtl("instance list --templates");
});
});
describe("instance config list", function() {
it("runs on an inheriting instance", async function() {
await execCtl("instance config list inheritChild");
});
});
describe("instance config inherit", function() {
it("runs", async function() {
await execCtl("instance config set inheritChild factorio.version 2.0");
let config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["instance.inherited_fields"]["factorio.version"], undefined);
await execCtl("instance config inherit inheritChild factorio.version");
await execCtl("instance config inherit inheritChild factorio.settings max_players");
config = await getControl().send(new lib.InstanceConfigGetRequest(4401));
assert.equal(config["factorio.version"], "2.1");
assert.equal(config["instance.inherited_fields"]["factorio.version"], true);
assert.equal(config["factorio.settings"].max_players, 7);
});
});
});
describe("instance assign", function() {
it("creates the instance files", async function() {
await execCtl("instance assign test 4");
await execCtl(`instance assign test ${hostId}`);
await assert.doesNotReject(
fs.access(path.join(instancesDir, "test")),
"Instance directory was not created"
@@ -1044,14 +1338,14 @@ describe("Integration of Clusterio", function() {
assert.equal(controllerEntry.host_id, undefined);
assert.equal(controllerEntry.instance_id, undefined);
const hostEntry = await findLog({ hostIds: [4] }, "test_plugin host echo log-tagging");
const hostEntry = await findLog({ hostIds: [hostId] }, "test_plugin host echo log-tagging");
assert.equal(hostEntry.plugin, "test_plugin");
assert.equal(hostEntry.host_id, 4);
assert.equal(hostEntry.host_id, hostId);
assert.equal(hostEntry.instance_id, undefined);
const instanceEntry = await findLog({ instanceIds: [44] }, "test_plugin instance echo log-tagging");
assert.equal(instanceEntry.plugin, "test_plugin");
assert.equal(instanceEntry.host_id, 4);
assert.equal(instanceEntry.host_id, hostId);
assert.equal(instanceEntry.instance_id, 44);
});
});
@@ -1521,7 +1815,7 @@ describe("Integration of Clusterio", function() {
before(async function() {
slowTest(this);
await execCtlProcess("instance create spam --id 66");
await execCtlProcess("instance assign spam 4");
await execCtlProcess(`instance assign spam ${hostId}`);
await execCtlProcess("instance create unassign --id 77");
});
after(async function() {
@@ -1548,7 +1842,7 @@ describe("Integration of Clusterio", function() {
hostProcess = await startAltHost();
await execCtlProcess("instance create save-test --id 88");
await execCtlProcess("instance assign save-test 5");
await execCtlProcess(`instance assign save-test ${altHost.hostId}`);
});
after(async function() {
await stopAltHost(hostProcess);
@@ -1976,9 +2270,9 @@ describe("Integration of Clusterio", function() {
describe("note set", function() {
it("should set the note of the given resource", async function() {
await execCtl("note set host 4 basement-box");
await execCtl(`note set host ${hostId} basement-box`);
const notes = await getControl().send(new lib.NoteListRequest());
const note = notes.find(n => n.id === "host/4");
const note = notes.find(n => n.id === `host/${hostId}`);
assert.equal(note.content, "basement-box");
assert.equal(note.updatedBy, "test");
});
@@ -1999,7 +2293,7 @@ describe("Integration of Clusterio", function() {
const originalLog = console.log; // eslint-disable-line no-console
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
try {
await execCtl("note get host 4");
await execCtl(`note get host ${hostId}`);
} finally {
console.log = originalLog; // eslint-disable-line no-console
}
@@ -2015,9 +2309,9 @@ describe("Integration of Clusterio", function() {
describe("note delete", function() {
it("should remove the note of the given resource", async function() {
await execCtl("note delete host 4");
await execCtl(`note delete host ${hostId}`);
const notes = await getControl().send(new lib.NoteListRequest());
assert(!notes.find(n => n.id === "host/4"), "Note was not deleted");
assert(!notes.find(n => n.id === `host/${hostId}`), "Note was not deleted");
});
});
+49 -9
View File
@@ -12,6 +12,7 @@ import { LineSplitter, logger } from "@clusterio/lib";
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
import { pairHost } from "@clusterio/host";
import "../setup_logging.js";
@@ -26,6 +27,7 @@ export class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
super(connector);
this.hostUpdates = [];
this.hostPairingUpdates = [];
this.instanceUpdates = [];
this.saveUpdates = [];
this.modUpdates = [];
@@ -39,6 +41,9 @@ export class TestControl extends lib.Link {
this.send(
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host pairing subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
@@ -58,6 +63,7 @@ export class TestControl extends lib.Link {
this.handle(lib.AccountUpdateEvent);
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
this.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.bind(this));
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
@@ -69,6 +75,10 @@ export class TestControl extends lib.Link {
this.hostUpdates.push(...event.updates);
}
async handleHostPairingUpdatesEvent(event) {
this.hostPairingUpdates.push(...event.updates);
}
async handleInstanceDetailsUpdatesEvent(event) {
this.instanceUpdates.push(...event.updates);
}
@@ -181,6 +191,8 @@ const baseHostConfig = loadJSON("config-host.json");
export let url = "https://localhost:4443/";
// Signed in the root before hook, as the user has to exist before the token is issued
export let controlToken;
// Id of the test host, assigned by the controller when it is paired in the root before hook
export let hostId;
export let instancesDir = path.join("temp", "test", "instances");
export let modsDir = path.join("temp", "test", "mods");
export let databaseDir = path.join("temp", "test", "database");
@@ -253,6 +265,35 @@ export function getControl() {
return control;
}
/**
* Pair a new host with the test controller, approved right away with the admin token
*
* @param {string} name - Name to give the host.
* @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host.
*/
export async function pairTestHost(name) {
return await pairHost({ controllerUrl: url, name, token: controlToken });
}
/**
* Write a new host config for a paired host, with the fields the installer and pairing set
*
* @param {string} configPath - Path to write the config to.
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
* @param {Record<string, unknown>} fields - Other fields to set in the config.
*/
export async function writeHostConfig(configPath, pairing, fields = {}) {
const hostConfig = new lib.HostConfig("host");
hostConfig.set("host.id", pairing.hostId);
hostConfig.set("host.name", pairing.name);
hostConfig.set("host.controller_url", url);
hostConfig.set("host.controller_token", pairing.token);
for (const [name, value] of Object.entries(fields)) {
hostConfig.set(name, value);
}
await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 });
}
/** @returns {Promise<child_process.ChildProcess>} */
export function spawn(name, cmd, waitFor, options = {}) {
@@ -270,22 +311,19 @@ export function spawn(name, cmd, waitFor, options = {}) {
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
let onData = line => {
line = line.toString("utf8");
if (waitFor.test(line)) {
if (silent) {
stdout.off("data", onDataOut);
stderr.off("data", onDataErr);
stdout.off("data", onData);
stderr.off("data", onData);
}
resolve(process);
}
log(name, line);
};
let onDataErr = line => {
log(name, line.toString("utf8"));
};
stdout.on("data", onDataOut);
stderr.on("data", onDataErr);
stdout.on("data", onData);
stderr.on("data", onData);
process.stdout.pipe(stdout);
process.stderr.pipe(stderr);
});
@@ -366,7 +404,9 @@ before(async function() {
haveFactorioInstall = factorioVersions.size > 0;
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await execCtlProcess("host create-config --id 4 --name host --generate-token");
const pairing = await pairTestHost("host");
hostId = pairing.hostId;
await writeHostConfig(hostConfigPath, pairing);
await execHost("config create");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
+25 -6
View File
@@ -8,7 +8,7 @@ import fs from "node:fs/promises";
import { slowTest, testMatrix } from "../common.js";
import {
execCtl, execCtlProcess, sendRcon, getControl,
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir,
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir, hostId,
} from "./index.js";
import { getTestRconPort } from "./rcon_port.js";
@@ -96,7 +96,7 @@ describe("Clusterio Instance", function() {
await execCtl(`instance config set ${instName} factorio.enable_whitelist true`);
await execCtlProcess(`instance config set-prop ${instName} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${instName} factorio.settings require_user_verification false`);
await execCtl(`instance assign ${instName} 4`);
await execCtl(`instance assign ${instName} ${hostId}`);
// Exclude main test instance from start-all to prevent interference
await execCtl(`instance config set ${instName} instance.exclude_from_start_all true`);
@@ -112,7 +112,7 @@ describe("Clusterio Instance", function() {
await execCtl(`instance config set ${instAltName} factorio.enable_whitelist true`);
await execCtlProcess(`instance config set-prop ${instAltName} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${instAltName} factorio.settings require_user_verification false`);
await execCtl(`instance assign ${instAltName} 4`);
await execCtl(`instance assign ${instAltName} ${hostId}`);
// Exclude alt test instance from start-all to prevent interference
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
await execCtl(`instance start ${instAltName}`);
@@ -153,6 +153,25 @@ describe("Clusterio Instance", function() {
assert.deepEqual(whitelist, ["test_user"]);
});
});
describe("save patching disabled", function() {
it("should remove the modules from a save patched before", async function() {
this.timeout(40000);
const hasApi = "/sc rcon.print(tostring(remote.interfaces.clusterio_api ~= nil))";
await execCtl(`${instSetConfig} factorio.enable_save_patching true`);
await execCtl(`instance save create ${instName}`);
await execCtl(`instance start ${instName}`);
assert.equal(await sendRcon(instId, hasApi), "true\n");
await execCtl(`instance stop ${instName}`);
await execCtl(`${instSetConfig} factorio.enable_save_patching false`);
await execCtl(`instance start ${instName}`);
try {
assert.equal(await sendRcon(instId, hasApi), "false\n");
} finally {
await execCtl(`instance stop ${instName}`);
}
});
});
for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) {
const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`;
const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`;
@@ -340,9 +359,9 @@ describe("start-all and stop-all commands", function() {
await execCtl(`instance create ${testInstName3} --id ${testInstId3}`);
// Configure instances
await execCtl(`instance assign ${testInstName1} 4`);
await execCtl(`instance assign ${testInstName2} 4`);
await execCtl(`instance assign ${testInstName3} 4`);
await execCtl(`instance assign ${testInstName1} ${hostId}`);
await execCtl(`instance assign ${testInstName2} ${hostId}`);
await execCtl(`instance assign ${testInstName3} ${hostId}`);
const visibility = JSON.stringify({ lan: false, public: false }).replace(
/"/g, process.platform === "win32" ? '""' : '\\"'
);
+29 -7
View File
@@ -3,25 +3,36 @@ import events from "node:events";
import jwt from "jsonwebtoken";
import {
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl,
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl, hostId, pairTestHost,
} from "./index.js";
import * as lib from "@clusterio/lib";
import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib";
import { slowTest } from "../common.js";
let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
// Valid token for a host id that was never approved
let tokenUnregistered = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
describe("Integration of lib/link", function() {
let control;
let controlConnector;
let hostConnector;
// Hosts can only connect once approved, so the host connections use a paired host
let linkHost;
before(async function() {
linkHost = await pairTestHost("link-test");
});
after(async function() {
if (linkHost) {
await getControl().send(new lib.HostRevokeRequest(linkHost.hostId));
}
});
beforeEach(async function() {
controlConnector = new TestControlConnector(url, 0.2);
controlConnector.token = controlToken;
control = new TestControl(controlConnector, false);
hostConnector = new TestHostConnector(url, 0.2);
hostConnector.token = tokenHost;
hostConnector.hostId = 0;
hostConnector.token = linkHost.token;
hostConnector.hostId = linkHost.hostId;
});
afterEach(async function() {
await controlConnector.disconnect();
@@ -138,13 +149,24 @@ describe("Integration of lib/link", function() {
});
it("should refuse connection with mismatched ids", async function() {
hostConnector.hostId = 1;
hostConnector.hostId = linkHost.hostId + 1;
await assert.rejects(
hostConnector.connect(),
new Error("Authentication failed: missmatched host id")
);
});
it("should refuse connection for a host that is not registered", async function() {
hostConnector.token = tokenUnregistered;
hostConnector.hostId = 0;
await assert.rejects(
hostConnector.connect(),
new Error(
"Authentication failed: host is not registered, start the host again to request to join the cluster"
)
);
});
it("should close old connections from the same address", async function() {
slowTest(this);
// Without a link the disconnect prepare goes unanswered and the controller waits 10 s for it
@@ -165,8 +187,8 @@ describe("Integration of lib/link", function() {
it("should refuse duplicate host connections", async function() {
this.skip(); // Can't be checked because the remote address for both connections is the same
hostConnector.token = jwt.sign({ aud: "host", host: 4 }, Buffer.from("TestSecretDoNotUse", "base64"));
hostConnector.hostId = 4; // Same as already running host
hostConnector.token = jwt.sign({ aud: "host", host: hostId }, Buffer.from("TestSecretDoNotUse", "base64"));
hostConnector.hostId = hostId; // Same as already running host
await assert.rejects(
hostConnector.connect(),
new Error("Authentication failed: missmatched host id")
+5 -2
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { Host, Instance, InstanceConnection } from "@clusterio/host";
import { ControlConnection, Controller, HostConnection, InstanceRecord } from "@clusterio/controller";
import { ControlConnection, Controller, HostConnection, HostRecord, InstanceRecord } from "@clusterio/controller";
import { MockServer } from "../mock.js";
// Although there are no imports, the file has side-effects such as "before" which can effect tests
@@ -19,6 +19,8 @@ class Control extends lib.Link { }
function connectHost(controller, hostId, plugins) {
const [controllerSide, hostSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ hostId }));
const registerData = new lib.RegisterHostData("", "0.0.0", hostId, plugins);
// Hosts can only connect once approved, which creates their record.
controller.hosts.records.set(new HostRecord(hostId, `host-${hostId}`, "", new Map()));
const hostConnection = new HostConnection(registerData, controllerSide, controller, "host-a.test");
controller.wsServer.hostConnections.set(hostId, hostConnection);
const hostConfig = new lib.HostConfig("host");
@@ -39,7 +41,8 @@ function connectInstance(controller, host, instanceId) {
instanceConfig.set("instance.id", instanceId);
instanceConfig.set("instance.assigned_host", host.config.get("host.id"));
host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig });
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
const controllerConfig = new lib.InstanceConfig("controller", instanceConfig.toRemote("controller"));
controller.instances.records.set(new InstanceRecord(controllerConfig, "stopped"));
const instance = new Instance(host, instanceSide, "invalid", instanceConfig, new MockServer());
const instanceConnection = new InstanceConnection(hostSide, host, instance);
host.instanceConnections.set(instanceId, instanceConnection);
+14
View File
@@ -145,6 +145,20 @@ describe("lib/config/classes", function() {
assert.equal(testInstance.get("test.func"), 42);
assert.equal(testInstance.get("test.priv"), null);
});
it("should call initialValue functions with the loaded config", function() {
class DerivedConfig extends lib.Config {
static defaultAccess = ["local"];
static fieldDefinitions = {
"test.base": { type: "number", initialValue: 1 },
"test.derived": { type: "number", initialValue: config => config.get("test.base") * 2 },
};
}
assert.equal(new DerivedConfig("local").get("test.derived"), 2);
assert.equal(new DerivedConfig("local", { "test.base": 5 }).get("test.derived"), 10);
assert.equal(new DerivedConfig("local", { "test.base": 5, "test.derived": 3 }).get("test.derived"), 3);
const invalid = new DerivedConfig("local", { "test.base": 5, "test.derived": "x" });
assert.equal(invalid.get("test.derived"), 10);
});
it("should not initalize inaccessible fields", function() {
let testInstance = new TestConfig("remote");
assert.equal(testInstance.fields["test.enum"], "b");
+53
View File
@@ -4,6 +4,59 @@ import * as lib from "@clusterio/lib";
describe("lib/config/definitions", function() {
describe("InstanceConfig", function() {
describe("isInheritable()", function() {
it("should allow regular fields", function() {
assert.equal(lib.InstanceConfig.isInheritable("factorio.version"), true);
assert.equal(lib.InstanceConfig.isInheritable("instance.auto_start"), true);
});
it("should reject identity, hidden, host only and unknown fields", function() {
for (const name of [
"instance.name", "instance.id", "instance.assigned_host", "instance.is_template",
"instance.template_id", "instance.inherited_fields", "factorio.host_assigned_game_port",
"factorio.game_port", "factorio.rcon_port", "factorio.rcon_password", "factorio.lua_udp_port",
"no.field",
]) {
assert.equal(lib.InstanceConfig.isInheritable(name), false, name);
}
});
});
describe(".isInherited()", function() {
it("should only report fields as inherited when a template is set", function() {
const config = new lib.InstanceConfig("controller", {
"instance.inherited_fields": { "factorio.version": true },
});
assert.equal(config.isInherited("factorio.version"), false);
config.set("instance.template_id", 1);
assert.equal(config.isInherited("factorio.version"), true);
assert.equal(config.isInherited("factorio.game_port"), false);
});
it("should report object properties not overridden as inherited", function() {
const config = new lib.InstanceConfig("controller", {
"instance.template_id": 1,
"instance.inherited_fields": { "factorio.settings": ["name"] },
});
assert.equal(config.isInherited("factorio.settings"), true);
assert.equal(config.isInherited("factorio.settings", "max_players"), true);
assert.equal(config.isInherited("factorio.settings", "name"), false);
});
});
it("should not send inheritance fields to the host", function() {
const config = new lib.InstanceConfig("controller", { "instance.template_id": 1 });
const remote = config.toRemote("host");
for (const name of ["instance.is_template", "instance.template_id", "instance.inherited_fields"]) {
assert(!Object.hasOwn(remote, name), name);
}
});
it("should not let control write inherited_fields", function() {
const config = new lib.InstanceConfig("controller");
assert.throws(
() => config.set("instance.inherited_fields", { "factorio.version": true }, "control"),
new lib.InvalidAccessError("Field 'instance.inherited_fields' is not accessible from control"),
);
});
});
describe("addPluginConfigFields()", function() {
after(function() {
delete lib.CtlConfig.fieldDefinitions["ctl_test.field"];
+43
View File
@@ -369,6 +369,49 @@ describe("lib/config/definitions/validators", function() {
assert.doesNotThrow(() => config.set("factorio.enable_lua_udp", true));
assert.doesNotThrow(() => config.set("lua_udp.load_plugin", true));
});
it("should default load_plugin to false when a required feature is disabled", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
}
lib.addPluginFieldDefinitions(
{ name: "requires_features", features: ["SavePatching", "ScriptCommands"] },
"instanceConfigFields", TestInstanceConfig
);
assert.equal(new TestInstanceConfig("controller").get("requires_features.load_plugin"), true);
for (const field of ["factorio.enable_save_patching", "factorio.enable_script_commands"]) {
const config = new TestInstanceConfig("controller", { [field]: false });
assert.equal(config.get("requires_features.load_plugin"), false);
// Other fields can be edited since the config is valid
assert.doesNotThrow(() => config.set("instance.name", "test"));
}
// An explicit value is kept
const config = new TestInstanceConfig("controller", {
"factorio.enable_save_patching": false,
"requires_features.load_plugin": true,
});
assert.equal(config.get("requires_features.load_plugin"), true);
});
it("should reject disabling a feature required by a loaded plugin", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
static _fieldDependents; // Don't reuse the cache from InstanceConfig
}
lib.addPluginFieldDefinitions(
{ name: "save_patching", features: ["SavePatching"] },
"instanceConfigFields", TestInstanceConfig
);
const config = new TestInstanceConfig("controller");
assert.throws(
() => config.set("factorio.enable_save_patching", false),
/requires save patching/
);
assert.equal(config.get("factorio.enable_save_patching"), true);
});
it("should allow when no feature flags are present", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
+18
View File
@@ -0,0 +1,18 @@
import { HostPairingDetails } from "@clusterio/lib";
import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js";
describe("lib/data/HostPairingDetails", function () {
it("should be round trip serialisable", function () {
testRoundTripJsonSerialisable(HostPairingDetails, testMatrix(
["ABC-DEF"], // code (required)
["New Host", ""], // name (required)
["10.0.0.1", "2001:db8::1"], // remoteAddress (required)
[undefined, "2.0.0"], // version
[undefined, 123456], // createdAtMs
[undefined, 999999], // updatedAtMs
[undefined, true], // isDeleted
));
});
});
+3
View File
@@ -16,6 +16,9 @@ describe("lib/data/InstanceDetails", function () {
[undefined, 1234], // startedAtMs
[undefined, 9999], // updatedAtMs
[undefined, true], // excludeFromStartAll
[undefined, true], // isTemplate
[undefined, 7], // templateId
[undefined, 3], // modPackId
));
});
+35
View File
@@ -346,6 +346,41 @@ describe("lib/datastore", function() {
await datastore.save();
assert.equal(datastoreProvider.methodCalls.length, 2);
});
it("should write overlapping saves in order with the latest data", async function() {
const written = [];
let release;
datastoreProvider.save = async data => {
const keys = [...data.keys()];
if (!written.length) {
await new Promise(resolve => { release = resolve; });
}
written.push(keys);
};
datastore = new MockDatastore(datastoreProvider, new Map([["a", 1]]));
datastore.callTouch();
const first = datastore.save();
await new Promise(resolve => setImmediate(resolve)); // First write in progress
datastore.getData().set("b", 2);
datastore.callTouch();
const second = datastore.save();
release();
await Promise.all([first, second]);
assert.deepEqual(written, [["a"], ["a", "b"]]);
});
it("should save again after a failed save", async function() {
let fail = true;
datastoreProvider.save = async () => {
if (fail) {
fail = false;
throw new Error("disk full");
}
datastoreProvider.methodCalls.push(["save"]);
};
datastore.callTouch();
await assert.rejects(datastore.save(), /disk full/);
await datastore.save();
assert.deepEqual(datastoreProvider.methodCalls.at(-1), ["save"]);
});
});
describe("load", function() {
+7 -7
View File
@@ -51,7 +51,7 @@ describe("lib/file_ops", function() {
});
});
describe("safeOutputFile()", function() {
describe("atomicOverwriteFile()", function() {
async function assertNoTemporary(target) {
let { dir, name } = path.parse(target);
let entries = (await fs.readdir(dir)).filter(
@@ -61,14 +61,14 @@ describe("lib/file_ops", function() {
}
it("should write new target file", async function() {
let target = path.join(baseDir, "safe", "simple.txt");
await lib.safeOutputFile(target, "a text file", "utf8");
await lib.atomicOverwriteFile(target, "a text file", "utf8");
await assertNoTemporary(target);
assert.equal(await fs.readFile(target, "utf8"), "a text file");
});
it("should overwrite existing target file", async function() {
let target = path.join(baseDir, "safe", "exists.txt");
await fs.writeFile(target, "previous", "utf8");
await lib.safeOutputFile(target, "current", "utf8");
await lib.atomicOverwriteFile(target, "current", "utf8");
await assertNoTemporary(target);
assert.equal(await fs.readFile(target, "utf8"), "current");
});
@@ -77,7 +77,7 @@ describe("lib/file_ops", function() {
this.skip();
}
let target = path.join(baseDir, "safe", "mode.txt");
await lib.safeOutputFile(target, "private", { mode: 0o600 });
await lib.atomicOverwriteFile(target, "private", { mode: 0o600 });
assert.equal((await fs.stat(target)).mode & 0o777, 0o600);
assert.equal(await fs.readFile(target, "utf8"), "private");
});
@@ -85,7 +85,7 @@ describe("lib/file_ops", function() {
let target = path.join(baseDir, "safe", "concurrent.txt");
let writes = [];
for (let i = 0; i < 20; i++) {
writes.push(lib.safeOutputFile(target, `write ${i}`, "utf8"));
writes.push(lib.atomicOverwriteFile(target, `write ${i}`, "utf8"));
}
// Ignore errors from concurrently renaming onto the same file.
await Promise.allSettled(writes);
@@ -95,13 +95,13 @@ describe("lib/file_ops", function() {
it("should remove the temporary file when the rename fails", async function() {
let target = path.join(baseDir, "safe", "rename-fails");
await fs.mkdir(target, { recursive: true });
await assert.rejects(lib.safeOutputFile(target, "data", "utf8"));
await assert.rejects(lib.atomicOverwriteFile(target, "data", "utf8"));
await assertNoTemporary(target);
});
it("should handle creating file in current working directory", async function() {
let target = "temporary-file-made-to-test-cwd.txt";
try {
await lib.safeOutputFile(target, "a text file", "utf8");
await lib.atomicOverwriteFile(target, "a text file", "utf8");
} finally {
try {
await fs.unlink(target);
+58
View File
@@ -1250,6 +1250,36 @@ describe("lib/subscriptions", function() {
});
});
describe("permitted", function() {
let permitted;
let subscriber;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
permitted = false;
subscriber = new lib.EventSubscriber(RegisteredEvent, mockControl, undefined, () => permitted);
});
it("should send nothing while not permitted", async function() {
subscriber.subscribe(() => {});
mockControl.connector.emit("connect");
subscriber.handleConnectionEvent("resume");
subscriber.handleAccountEvent();
await new Promise(r => setImmediate(r));
assertNoMessageSent();
});
it("should subscribe when the account becomes permitted", async function() {
subscriber.subscribe(() => {});
permitted = true;
subscriber.handleAccountEvent();
await onceConnectorSend();
assertLastMessage(new lib.SubscriptionRequest(
RegisteredEvent.name, "replace", -1, lib.SubscriptionFilters.all()
));
});
});
describe(".handleAccountEvent()", function() {
it("should send replace(all) if subscribed", async function() {
eventSubscriber.subscribe(() => {});
@@ -1584,6 +1614,34 @@ describe("lib/subscriptions", function() {
assert.equal(snap2.has(2), true);
});
it("should fetch everything again when a new session starts", async function() {
subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
await subscriber._handleEvent(new MapEvent([
{ id: 1, updatedAtMs: 10, isDeleted: false },
]));
const [before] = subscriber.getSnapshot();
mockControl.connector.sentMessages.length = 0;
mockControl.connector.emit("connect");
assert.equal(subscriber.values.size, 0);
assert.notStrictEqual(subscriber.getSnapshot()[0], before);
await new Promise(resolve => setImmediate(resolve));
const requests = mockControl.connector.sentMessages.filter(m => m.name === "SubscriptionRequest");
assert.equal(requests.length, 1);
assert.equal(requests[0].data.lastRequestTimeMs, -1);
});
it("should keep values when a session is resumed", async function() {
await subscriber._handleEvent(new MapEvent([
{ id: 1, updatedAtMs: 10, isDeleted: false },
]));
subscriber.handleConnectionEvent("drop");
subscriber.handleConnectionEvent("resume");
assert.equal(subscriber.values.size, 1);
assert.equal(subscriber.lastUpdatedMs, 10);
});
it("should handle multiple updates correctly", async function() {
await subscriber._handleEvent(new MapEvent([
{ id: 1, updatedAtMs: 10, isDeleted: false },
+28
View File
@@ -200,6 +200,34 @@ describe("messages/controller", function() {
});
});
describe("Host pairing", function() {
const details = [
new lib.HostPairingDetails("ABC-DEF", "Host A", "10.0.0.1", "2.0.0", 100, 200),
new lib.HostPairingDetails("GHJ-KMN", "Host B", "2001:db8::1", "", 0, 300, true),
];
it("round trips HostPairingListRequest responses", function() {
const validate = lib.compile(lib.HostPairingListRequest.Response.jsonSchema);
const json = JSON.parse(JSON.stringify(details));
assert(validate(json), JSON.stringify(validate.errors));
assert.deepEqual(lib.HostPairingListRequest.Response.fromJSON(json), details);
});
it("round trips HostPairingUpdatesEvent", function() {
testRoundTripJsonSerialisable(lib.HostPairingUpdatesEvent, [[[]], [details]]);
});
it("round trips HostPairingApproveRequest", function() {
testRoundTripJsonSerialisable(lib.HostPairingApproveRequest, testMatrix(
["ABC-DEF", "abcdef"], // code
[undefined, "Renamed"], // name
));
const validate = lib.compile(lib.HostPairingApproveRequest.Response.jsonSchema);
assert(validate(1234));
});
it("round trips HostPairingRejectRequest", function() {
testRoundTripJsonSerialisable(lib.HostPairingRejectRequest, [["ABC-DEF"], ["abcdef"]]);
});
});
describe("InstanceConfigGetRequest", function() {
it("runs", async function() {
const config = await controlConnection.handleInstanceConfigGetRequest(
+6
View File
@@ -18,6 +18,12 @@ describe("messages/host", function() {
host = new Host(hostConnector, hostConfig, []);
});
describe("HostRevokeRequest", function() {
it("is round trip json serialisable", function() {
testRoundTripJsonSerialisable(lib.HostRevokeRequest, [[1], [2 ** 31 - 1]]);
});
});
describe("HostUpdateRequest", function() {
it("runs", async function() {
host.config.set("host.allow_remote_updates", true);
+4 -2
View File
@@ -3,7 +3,7 @@ import http from "node:http";
import express from "express";
import * as lib from "@clusterio/lib";
import { User, UserManager, UserRecord, InstanceManager, ControllerHooks } from "@clusterio/controller";
import { User, UserManager, UserRecord, HostManager, InstanceManager, ControllerHooks } from "@clusterio/controller";
import { HostHooks, InstanceHooks } from "@clusterio/host";
const addr = lib.Address.fromShorthand;
@@ -176,6 +176,8 @@ export class MockController {
["controller.auth_secret", "TestSecretDoNotUse"],
["controller.proxy_stream_timeout", 1],
["controller.metrics_timeout", 1],
["controller.host_pairing_timeout", 3600],
["controller.host_pairing_max_pending", 50],
]);
this.config = {
get: (name) => {
@@ -206,7 +208,7 @@ export class MockController {
this.instances = new InstanceManager(new lib.KeyValueDatastore(), this);
this.hosts = new lib.KeyValueDatastore();
this.hosts = new HostManager(new lib.SubscribableDatastore(), this);
this.handles = new Map();
this.hooks = new ControllerHooks(new MockLogger());
this.loadedPlugins = new Set();
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { MockConnector, MockControl } from "../mock.js";
import "../setup_logging.js";
import { HostPairingSubscriber } from "../../packages/web_ui/src/util/host_pairing.ts";
const addr = lib.Address.fromShorthand;
describe("web_ui/util/host_pairing", function() {
describe("class HostPairingSubscriber", function() {
let mockControl;
let subscriber;
beforeEach(function() {
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
subscriber = new HostPairingSubscriber(mockControl, () => true);
});
function pairing(code, updatedAtMs) {
return new lib.HostPairingDetails(code, `Host ${code}`, "192.0.2.1", "", updatedAtMs, updatedAtMs);
}
async function settle() {
await new Promise(resolve => setImmediate(resolve));
}
function sentRequests() {
return mockControl.connector.sentMessages.filter(message => message.name === "SubscriptionRequest");
}
it("should clear requests when subscribing again after the last unsubscribe", async function() {
const unsubscribe = subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
unsubscribe();
await new Promise(resolve => setTimeout(resolve));
mockControl.connector.sentMessages.length = 0;
subscriber.subscribe(() => {});
assert.equal(subscriber.values.size, 0);
await settle();
assert.equal(sentRequests().length, 1);
assert.equal(sentRequests()[0].data.lastRequestTimeMs, -1);
});
it("should keep requests when a page that subscribes replaces another", async function() {
const unsubscribe = subscriber.subscribe(() => {});
await events.once(mockControl.connector, "send");
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
unsubscribe();
subscriber.subscribe(() => {});
assert.equal(subscriber.values.size, 1);
assert.equal(subscriber.lastUpdatedMs, 10);
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { instanceModPack } from "../../packages/web_ui/src/util/instance.ts";
function modPack(id, name) {
return lib.ModPack.fromJSON({ id, name, factorio_version: "2.0" });
}
describe("web_ui/util/instance", function() {
describe("instanceModPack()", function() {
const base = modPack(1, "Base");
const space = modPack(2, "Space Age");
const modPacks = new Map([[1, base], [2, space]]);
it("should use the mod pack set on the instance", function() {
assert.deepEqual(instanceModPack(2, modPacks, base), { id: 2, modPack: space, isDefault: false });
});
it("should keep the id of a mod pack that is not known", function() {
assert.deepEqual(instanceModPack(9, modPacks, base), { id: 9, modPack: undefined, isDefault: false });
});
it("should fall back to the controller default", function() {
assert.deepEqual(instanceModPack(undefined, modPacks, base), { id: 1, modPack: base, isDefault: true });
});
it("should prefer the subscribed copy of the default mod pack", function() {
const stale = modPack(1, "Old name");
assert.equal(instanceModPack(undefined, modPacks, stale).modPack, base);
assert.equal(instanceModPack(undefined, new Map(), stale).modPack, stale);
});
it("should mark the default as unknown when it is not available", function() {
assert.deepEqual(instanceModPack(undefined, modPacks, undefined), { isDefault: true });
});
});
});
+113
View File
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import {
modSettingDescription, modSettingName, modSettingValueName, resolveLocalisedString, stripRichText,
} from "../../packages/web_ui/src/util/locale.ts";
describe("web_ui/util/locale", function() {
const locale = new Map([
["phi-cl.combine", "__1__ __2__"],
["mod-setting-name-prefix.PHI-MB", "[color=255,254,42]MB[/color]"],
["mod-setting-name.PHI-MB", "[color=255,254,42]Enable better entity[/color]"],
["mod-setting-name.plain", "Plain setting"],
["mod-setting-description.plain", "Plain description"],
["string-mod-setting.mode-fast", "[font=default-bold]Fast[/font]"],
["item-name.iron-plate", "Iron plate"],
["entity-name.inserter", "Inserter"],
["uses", "Uses __ITEM__iron-plate__ and __ENTITY__inserter__"],
["greeting", "Hello __1__"],
]);
describe("resolveLocalisedString()", function() {
it("should return plain strings and numbers as is", function() {
assert.equal(resolveLocalisedString(locale, "text"), "text");
assert.equal(resolveLocalisedString(locale, 5), "5");
});
it("should substitute parameters", function() {
assert.equal(resolveLocalisedString(locale, ["greeting", "world"]), "Hello world");
assert.equal(resolveLocalisedString(locale, ["greeting", ["greeting", 2]]), "Hello Hello 2");
});
it("should leave parameters that were not given", function() {
assert.equal(resolveLocalisedString(locale, ["greeting"]), "Hello __1__");
});
it("should substitute prototype names", function() {
assert.equal(resolveLocalisedString(locale, ["uses"]), "Uses Iron plate and Inserter");
});
it("should concatenate with an empty key", function() {
assert.equal(resolveLocalisedString(locale, ["", "a", ["greeting", "b"], 3]), "aHello b3");
});
it("should use the first alternative that resolves with a ? key", function() {
assert.equal(resolveLocalisedString(locale, ["?", ["missing"], ["greeting", "x"], "y"]), "Hello x");
assert.equal(resolveLocalisedString(locale, ["?", ["missing"]]), undefined);
});
it("should return undefined if a key is missing", function() {
assert.equal(resolveLocalisedString(locale, ["missing"]), undefined);
assert.equal(resolveLocalisedString(locale, ["greeting", ["missing"]]), undefined);
});
it("should return undefined for nesting that is too deep", function() {
let value = "end";
for (let i = 0; i < 30; i++) {
value = ["", value];
}
assert.equal(resolveLocalisedString(locale, value), undefined);
});
});
describe("stripRichText()", function() {
it("should remove formatting tags", function() {
assert.equal(stripRichText(locale, "[color=red]A[/color] [font=default-bold]B[/font]"), "A B");
});
it("should replace prototype icons with their name", function() {
assert.equal(stripRichText(locale, "[item=iron-plate] plates"), "Iron plate plates");
assert.equal(stripRichText(locale, "[item=iron-plate,quality=rare]"), "Iron plate");
assert.equal(stripRichText(locale, "[fluid=unknown]"), "unknown");
});
it("should remove other icons", function() {
assert.equal(stripRichText(locale, "[img=utility/warning_icon] Warning"), "Warning");
});
it("should keep brackets that are not tags", function() {
assert.equal(stripRichText(locale, "[Beta] [x=1]"), "[Beta] [x=1]");
});
});
describe("modSettingName()", function() {
it("should resolve localised_name", function() {
const prototype = {
name: "PHI-MB",
localised_name: ["phi-cl.combine", ["mod-setting-name-prefix.PHI-MB"], ["mod-setting-name.PHI-MB"]],
};
assert.equal(modSettingName(locale, prototype), "MB Enable better entity");
});
it("should fall back to the mod-setting-name key", function() {
assert.equal(modSettingName(locale, { name: "plain" }), "Plain setting");
assert.equal(modSettingName(locale, { name: "plain", localised_name: ["missing"] }), "Plain setting");
});
it("should fall back to the internal name", function() {
assert.equal(modSettingName(locale, { name: "other" }), "other");
assert.equal(modSettingName(new Map(), { name: "plain" }), "plain");
});
});
describe("modSettingDescription()", function() {
it("should resolve localised_description", function() {
const prototype = { name: "plain", localised_description: ["greeting", "desc"] };
assert.equal(modSettingDescription(locale, prototype), "Hello desc");
});
it("should fall back to the mod-setting-description key", function() {
assert.equal(modSettingDescription(locale, { name: "plain" }), "Plain description");
});
it("should return undefined without a description", function() {
assert.equal(modSettingDescription(locale, { name: "other" }), undefined);
});
});
describe("modSettingValueName()", function() {
it("should resolve the string-mod-setting key", function() {
assert.equal(modSettingValueName(locale, "mode", "fast"), "Fast");
});
it("should fall back to the value", function() {
assert.equal(modSettingValueName(locale, "mode", "slow"), "slow");
assert.equal(modSettingValueName(locale, "mode", 3), "3");
});
});
});
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { modSettingFields } from "../../packages/web_ui/src/util/mod_settings.ts";
describe("web_ui/util/mod_settings", function() {
describe("modSettingFields()", function() {
const stored = new Map([
["kept", { value: true }],
["gone-number", { value: 2 }],
["gone-color", { value: { r: 1, g: 0, b: 0, a: 1 } }],
["gone-string", { value: "x" }],
]);
const prototypes = {
"bool-setting": {
"kept": { type: "bool-setting", name: "kept", mod: "b", setting_type: "startup", default_value: false },
"map": {
type: "bool-setting", name: "map", mod: "a", setting_type: "runtime-global", default_value: false,
},
},
"string-setting": {
"first": {
type: "string-setting", name: "first", mod: "a", setting_type: "startup",
default_value: "x", allowed_values: ["x", "y"],
localised_name: ["name.first"], localised_description: ["description.first"],
},
},
"unrelated": { "gone-number": { type: "unrelated", name: "gone-number", setting_type: "startup" } },
};
it("should split stored settings no prototype defines into unknown", function() {
const { known, unknown } = modSettingFields(prototypes, "startup", stored);
assert.deepEqual(known.map(field => field.name), ["first", "kept"]);
assert.deepEqual(known[0].allowed_values, ["x", "y"]);
assert.deepEqual(known[0].localised_name, ["name.first"]);
assert.deepEqual(known[0].localised_description, ["description.first"]);
assert.deepEqual(
unknown.map(field => [field.name, field.type]),
[["gone-color", "color-setting"], ["gone-number", "double-setting"], ["gone-string", "string-setting"]],
);
});
it("should only use prototypes of the given scope", function() {
const kept = new Map([["kept", { value: true }]]);
const { known, unknown } = modSettingFields(prototypes, "runtime-global", kept);
assert.deepEqual(known.map(field => field.name), ["map"]);
assert.deepEqual(unknown.map(field => field.name), ["kept"]);
});
it("should not call settings unknown without export data", function() {
const { known, unknown } = modSettingFields(undefined, "startup", stored);
assert.deepEqual(known.map(field => field.name), [...stored.keys()]);
assert(known.every(field => field.mod === undefined));
assert.deepEqual(unknown, []);
});
it("should treat every stored setting as unknown if the export has no settings", function() {
const { known, unknown } = modSettingFields({}, "startup", stored);
assert.deepEqual(known, []);
assert.equal(unknown.length, stored.size);
});
});
});
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { getResourceStatus } from "../../packages/web_ui/src/util/useResourceState.ts";
describe("web_ui/util/useResourceState", function() {
describe("getResourceStatus()", function() {
const error = new Error("Permission denied");
it("should be ok when the resource exists", function() {
assert.equal(getResourceStatus(true, true, true, null), "ok");
});
it("should keep showing a resource after the subscription stopped syncing", function() {
assert.equal(getResourceStatus(true, true, false, null), "ok");
assert.equal(getResourceStatus(true, true, false, error), "ok");
});
it("should be loading until the subscription has synced", function() {
assert.equal(getResourceStatus(false, false, false, null), "loading");
});
it("should be loading while resyncing a resource that was seen", function() {
assert.equal(getResourceStatus(false, true, false, null), "loading");
});
it("should be missing when synced without having seen the resource", function() {
assert.equal(getResourceStatus(false, false, true, null), "missing");
});
it("should be deleted when synced after having seen the resource", function() {
assert.equal(getResourceStatus(false, true, true, null), "deleted");
});
it("should be error when the subscription failed", function() {
assert.equal(getResourceStatus(false, false, false, error), "error");
assert.equal(getResourceStatus(false, true, false, error), "error");
});
it("should ignore the error of an earlier attempt once synced", function() {
assert.equal(getResourceStatus(false, false, true, error), "missing");
assert.equal(getResourceStatus(false, true, true, error), "deleted");
});
});
});