import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import * as lib from "@clusterio/lib"; import { Controller, ControlConnection } from "@clusterio/controller"; import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; import "../setup_logging.js"; describe("messages/controller", function() { /** @type {Controller} */ let controller; /** @type {ControlConnection} */ let controlConnection; /** @type {lib.InstanceConfig} */ let instanceConfig; beforeEach(function() { const controllerConfig = new lib.ControllerConfig("controller"); const connection = new lib.VirtualConnector( lib.Address.fromShorthand("controller"), lib.Address.fromShorthand({ controlId: 1 }), ); controller = new Controller(lib.logger, [], controllerConfig); instanceConfig = new lib.InstanceConfig("controller"); controller.instances.createInstance(instanceConfig); const user = controller.users.getOrCreateUser("test"); controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); }); describe("ControllerMarkRestartRequiredRequest", function() { it("runs", async function() { await controlConnection.handleControllerMarkRestartRequiredRequest( new lib.ControllerMarkRestartRequiredRequest() ); assert.equal(controller.config.restartRequired, true); }); }); describe("ControllerUpdateRequest", function() { it("runs", async function() { controller.config.set("controller.allow_remote_updates", true); await controlConnection.handleControllerUpdateRequest(new lib.ControllerUpdateRequest()); }); it("rejects if updates are disabled", async function() { controller.config.set("controller.allow_remote_updates", false); await assert.rejects( controlConnection.handleControllerUpdateRequest(new lib.ControllerUpdateRequest()), /Remote updates are disabled on this machine/ ); }); }); describe("RoleDeleteRequest", function() { it("removes the role from users and sends a user update", async function() { const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", [])); const user = controller.users.getOrCreateUser("player"); user.addRole(id); const updated = []; controller.users.records.on("update", users => updated.push(...users)); await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id)); assert.equal(controller.roles.has(id), false); assert.equal(user.roleIds.has(id), false); assert(updated.some(u => u.name === "player" && !u.roleIds.has(id)), "no user update sent"); }); }); describe("ControllerConfigGetRequest", function() { it("runs", async function() { const config = await controlConnection.handleControllerConfigGetRequest( new lib.ControllerConfigGetRequest() ); assert.deepEqual(config, controller.config.toRemote("control")); }); }); describe("ControllerConfigSetFieldRequest", function() { it("is round trip json serialisable", function() { testRoundTripJsonSerialisable(lib.ControllerConfigSetFieldRequest, testMatrix( ["controller.name", "invalid"], // field ["Foo Bar", "true", "5", "{}"], // value )); }); it("runs", async function() { await controlConnection.handleControllerConfigSetFieldRequest( new lib.ControllerConfigSetFieldRequest("controller.name", "Foo Bar") ); assert.equal(controller.config.get("controller.name"), "Foo Bar"); }); it("rejects if the field does not exist", async function() { await assert.rejects( controlConnection.handleControllerConfigSetFieldRequest( new lib.ControllerConfigSetFieldRequest("invalid", "Foo Bar") ), /No field named 'invalid'/ ); }); it("rejects if the field is inaccessible", async function() { await assert.rejects( controlConnection.handleControllerConfigSetFieldRequest( new lib.ControllerConfigSetFieldRequest("controller.version", "2.0.0") ), /Field 'controller.version' is not accessible from control/ ); }); }); describe("ControllerConfigSetPropRequest", function() { it("is round trip json serialisable", function() { testRoundTripJsonSerialisable(lib.ControllerConfigSetPropRequest, testMatrix( ["controller.name", "invalid"], // field ["foo", "bar"], // prop ["Foo Bar", true, 5, {}], // value )); }); it("runs", async function() { this.skip(); // No object fields on controller }); it("rejects if the field does not exist", async function() { await assert.rejects( controlConnection.handleControllerConfigSetPropRequest( new lib.ControllerConfigSetPropRequest("invalid", "foo", "bar") ), /No field named 'invalid'/ ); }); it("rejects if the field is not an object", async function() { await assert.rejects( controlConnection.handleControllerConfigSetPropRequest( new lib.ControllerConfigSetPropRequest("controller.name", "foo", "bar") ), /Cannot set property on non-object field 'controller\.name'/ ); }); it("rejects if the field is inaccessible", async function() { await assert.rejects( controlConnection.handleControllerConfigSetPropRequest( new lib.ControllerConfigSetPropRequest("controller.version", "foo", "bar") ), /Field 'controller\.version' is not accessible from control/ ); }); }); describe("ControllerConfigSetRequest", function() { it("is round trip json serialisable", function() { testRoundTripJsonSerialisable(lib.ControllerConfigSetRequest, testMatrix( [ // fields { "foo": "bar", "baz": { "cat": "dog" } }, { "foo": "5", "baz": { "cat": "5" } }, { "foo": "true", "baz": { "cat": "true" } }, { "foo": "{}", "baz": { "cat": "{}" } }, ], )); }); it("runs", async function() { await controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({ "controller.name": "Foo Bar", "controller.mods_directory": "Bar Baz", })); assert.equal(controller.config.get("controller.name"), "Foo Bar"); assert.equal(controller.config.get("controller.mods_directory"), "Bar Baz"); }); it("rejects if the field does not exist", async function() { await assert.rejects( controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({ "controller.name": "Foo Bar", "invalid": "Bar Baz", })), /No field named 'invalid'/ ); assert.equal(controller.config.get("controller.name"), "Your Cluster"); }); it("rejects if the field is not an object", async function() { const prevDir = controller.config.get("controller.mods_directory"); await assert.rejects( controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({ "controller.name": "Foo Bar", "controller.mods_directory": { "foo": "bar", }, })), /Cannot set property on non-object field 'controller\.mods_directory'/ ); assert.equal(controller.config.get("controller.name"), "Your Cluster"); assert.equal(controller.config.get("controller.mods_directory"), prevDir); }); it("rejects if the field is inaccessible", async function() { const prevVersion = controller.config.get("controller.version"); await assert.rejects( controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({ "controller.name": "Foo Bar", "controller.version": "2.0.0", })), /Field 'controller\.version' is not accessible from control/ ); assert.equal(controller.config.get("controller.name"), "Your Cluster"); assert.equal(controller.config.get("controller.version"), prevVersion); }); }); 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( new lib.InstanceConfigGetRequest(instanceConfig.get("instance.id")) ); assert.deepEqual(config, instanceConfig.toRemote("control")); }); }); describe("InstanceConfigSetFieldRequest", function() { it("is round trip json serialisable", function() { testRoundTripJsonSerialisable(lib.InstanceConfigSetFieldRequest, testMatrix( [1], // instance id ["controller.name", "invalid"], // field ["Foo Bar", "true", "5", "{}"], // value )); }); it("runs", async function() { await controlConnection.handleInstanceConfigSetFieldRequest( new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "instance.name", "Foo Bar") ); assert.equal(instanceConfig.get("instance.name"), "Foo Bar"); }); it("rejects if the field does not exist", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetFieldRequest( new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "invalid", "Foo Bar") ), /No field named 'invalid'/ ); }); it("rejects if the field is inaccessible", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetFieldRequest( new lib.InstanceConfigSetFieldRequest( instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "3000" ) ), /Field 'factorio\.host_assigned_game_port' is not accessible from control/ ); }); it("rejects setting 'instance.assigned_host'", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetFieldRequest( new lib.InstanceConfigSetFieldRequest( instanceConfig.get("instance.id"), "instance.assigned_host", 5 ) ), /instance\.assigned_host must be set through the assign-host interface/ ); }); it("rejects setting 'instance.id'", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetFieldRequest( new lib.InstanceConfigSetFieldRequest( instanceConfig.get("instance.id"), "instance.id", 5 ) ), /Setting instance\.id is not supported/ ); }); }); describe("InstanceConfigSetPropRequest", function() { it("is round trip json serialisable", function() { testRoundTripJsonSerialisable(lib.InstanceConfigSetPropRequest, testMatrix( [1], // instance id ["controller.name", "invalid"], // field ["foo", "bar"], // prop ["Foo Bar", true, 5, {}], // value )); }); it("runs", async function() { controlConnection.handleInstanceConfigSetPropRequest( new lib.InstanceConfigSetPropRequest( instanceConfig.get("instance.id"), "factorio.settings", "name", "Foo Bar" ) ); const { name, ...rest } = instanceConfig.get("factorio.settings"); assert.equal(name, "Foo Bar"); }); it("rejects if the field does not exist", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetPropRequest( new lib.InstanceConfigSetPropRequest(instanceConfig.get("instance.id"), "invalid", "foo", "bar") ), /No field named 'invalid'/ ); }); it("rejects if the field is not an object", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetPropRequest( new lib.InstanceConfigSetPropRequest( instanceConfig.get("instance.id"), "instance.name", "foo", "bar" ) ), /Cannot set property on non-object field 'instance\.name'/ ); }); it("rejects if the field is inaccessible", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetPropRequest( new lib.InstanceConfigSetPropRequest( instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "foo", "bar" ) ), /Field 'factorio\.host_assigned_game_port' is not accessible from control/ ); }); }); describe("InstanceConfigSetRequest", function() { it("is round trip json serialisable", function() { testRoundTripJsonSerialisable(lib.InstanceConfigSetRequest, testMatrix( [1], // instance id [ // fields { "foo": "bar", "baz": { "cat": "dog" } }, { "foo": "5", "baz": { "cat": "5" } }, { "foo": "true", "baz": { "cat": "true" } }, { "foo": "{}", "baz": { "cat": "{}" } }, ], )); }); it("runs", async function() { await controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest( instanceConfig.get("instance.id"), { "instance.name": "Foo Bar", "factorio.executable_path": "Bar Baz", }) ); assert.equal(instanceConfig.get("instance.name"), "Foo Bar"); assert.equal(instanceConfig.get("factorio.executable_path"), "Bar Baz"); }); it("rejects if the field does not exist", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest( instanceConfig.get("instance.id"), { "instance.name": "Foo Bar", "invalid": "Bar Baz", }) ), /No field named 'invalid'/ ); assert.equal(instanceConfig.get("instance.name"), "New Instance"); }); it("rejects if the field is not an object", async function() { const prevPath = instanceConfig.get("factorio.executable_path"); await assert.rejects( controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest( instanceConfig.get("instance.id"), { "instance.name": "Foo Bar", "factorio.executable_path": { "foo": "bar", }, }) ), /Cannot set property on non-object field 'factorio\.executable_path'/ ); assert.equal(instanceConfig.get("instance.name"), "New Instance"); assert.equal(instanceConfig.get("factorio.executable_path"), prevPath); }); it("rejects if the field is inaccessible", async function() { await assert.rejects( controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest( instanceConfig.get("instance.id"), { "instance.name": "Foo Bar", "factorio.host_assigned_game_port": "Bar Baz", }) ), /Field 'factorio\.host_assigned_game_port' is not accessible from control/ ); assert.equal(instanceConfig.get("instance.name"), "New Instance"); // Can't check value of host_assigned_game_port as it is not accessible on controller }); it("rejects setting 'instance.assigned_host'", async function() { const prevHost = instanceConfig.get("instance.assigned_host"); await assert.rejects( controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest( instanceConfig.get("instance.id"), { "instance.name": "Foo Bar", "instance.assigned_host": 5, }) ), /instance\.assigned_host must be set through the assign-host interface/ ); assert.equal(instanceConfig.get("instance.name"), "New Instance"); assert.equal(instanceConfig.get("instance.assigned_host"), prevHost); }); it("rejects setting 'instance.id'", async function() { const prevId = instanceConfig.get("instance.id"); await assert.rejects( controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest( instanceConfig.get("instance.id"), { "instance.name": "Foo Bar", "instance.id": 5, }) ), /Setting instance\.id is not supported/ ); assert.equal(instanceConfig.get("instance.name"), "New Instance"); assert.equal(instanceConfig.get("instance.id"), prevId); }); }); describe("External", function() { it("runs", async function() { let callCount = 0; const versions = [ { version: "1.2.3", stable: true, headlessUrl: "path/to/download" }, { version: "1.2.4", stable: false, headlessUrl: "path/to/download" }, ]; // Mock the cache to avoid needless fetches controller.factorioVersions = new lib.ValueCache(async () => { callCount += 1; return versions; }); // First call should result in a fetch const versions1 = await controlConnection.handleFactorioVersionsRequest( new lib.FactorioVersionsRequest() ); assert.deepEqual(versions1, versions); assert.equal(callCount, 1); // Second call should not fetch due to default maxAgeMs being greater than 0 const versions2 = await controlConnection.handleFactorioVersionsRequest( new lib.FactorioVersionsRequest() ); assert.deepEqual(versions2, versions); assert.equal(callCount, 1); // Setting maxAgeMs to 0 should result in a new fetch const versions3 = await controlConnection.handleFactorioVersionsRequest( new lib.FactorioVersionsRequest(0) ); assert.deepEqual(versions3, versions); assert.equal(callCount, 2); }); it("round trips LatestReleasesRequest", function() { testRoundTripJsonSerialisable(lib.LatestReleasesRequest, [[], [0], [60000]]); }); it("caches the latest releases and persists them for offline use", async function() { const releases = { stable: { headless: "2.0.77" }, experimental: { headless: "2.1.8" }, }; const databaseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-test-")); controller.config.set("controller.database_directory", databaseDirectory); const cachePath = path.join(databaseDirectory, "latest-releases.json"); const originalFetch = global.fetch; try { let callCount = 0; global.fetch = async () => { callCount += 1; return { ok: true, status: 200, statusText: "OK", json: async () => releases }; }; // First call fetches from the api and persists the result to disk const fetched = await controlConnection.handleLatestReleasesRequest( new lib.LatestReleasesRequest(0) ); assert.deepEqual(fetched, releases); assert.equal(callCount, 1); assert.deepEqual(JSON.parse(await fs.readFile(cachePath, "utf8")), releases); // When the api is unavailable the persisted copy is served instead global.fetch = async () => { throw new Error("Network Error"); }; const cached = await controlConnection.handleLatestReleasesRequest( new lib.LatestReleasesRequest(0) ); assert.deepEqual(cached, releases); // With no persisted copy and an unavailable api an empty object is returned await fs.rm(cachePath); const empty = await controlConnection.handleLatestReleasesRequest( new lib.LatestReleasesRequest(0) ); assert.deepEqual(empty, {}); } finally { global.fetch = originalFetch; await fs.rm(databaseDirectory, { recursive: true, force: true }); } }); }); });