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 { MockConnector, MockController, MockLogger } from "../mock.js"; const addr = lib.Address.fromShorthand; describe("controller/src/ControlConnection", function() { describe(".handleDebugDumpWsRequest()", function() { function makeConnection() { const controllerConfig = new lib.ControllerConfig("controller"); const connector = new lib.VirtualConnector( lib.Address.fromShorthand("controller"), lib.Address.fromShorthand({ controlId: 1 }), ); connector._socket = {}; const controller = new Controller(lib.logger, [], controllerConfig); const user = controller.users.getOrCreateUser("test"); return new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1); } it("re-applies clusterio_ignore_dump to the socket on resume", async function() { const connection = makeConnection(); await connection.handleDebugDumpWsRequest(new lib.DebugDumpWsRequest()); // A resume installs a fresh socket without the flag. const resumedSocket = {}; connection.connector._socket = resumedSocket; connection.connector.emit("resume"); assert.equal(resumedSocket.clusterio_ignore_dump, true); }); it("leaves the flag off on resume without a dumper", function() { const connection = makeConnection(); const resumedSocket = {}; connection.connector._socket = resumedSocket; connection.connector.emit("resume"); assert.equal(resumedSocket.clusterio_ignore_dump, false); }); }); describe(".handleControllerRestartRequest()", function() { let mockController; beforeEach(function() { mockController = { canRestart: true, shouldRestart: false, stopped: false, async checkRestartDowngrade() { return null; }, stop() { this.stopped = true; }, }; }); async function restart() { await ControlConnection.prototype.handleControllerRestartRequest.call({ _controller: mockController, }); } it("restarts when the installed version is not older", async function() { await restart(); assert.equal(mockController.shouldRestart, true); assert.equal(mockController.stopped, true); }); it("rejects a downgrade without stopping the controller", async function() { mockController.checkRestartDowngrade = async () => ({ installedVersion: "1.0.0", runningVersion: "2.0.0", }); await assert.rejects(restart, /Stop the controller before starting the older version manually/); assert.equal(mockController.shouldRestart, false); assert.equal(mockController.stopped, false); }); }); describe(".handlePluginInstallRequest()", function() { let mockController; let _fetch; beforeEach(function() { mockController = { config: new lib.ControllerConfig("controller", { "controller.allow_plugin_install": true }), pluginInfos: [{ name: "restart_test", npmPackage: "restart_test", version: "1.0.0" }], }; _fetch = global.fetch; global.fetch = async () => ({ ok: true }); }); afterEach(function() { global.fetch = _fetch; }); async function install(name) { await ControlConnection.prototype.handlePluginInstallRequest.call( { _controller: mockController }, new lib.PluginInstallRequest(name), ); } it("marks a restart as required for a new plugin", async function() { await install("new_plugin"); assert.equal(mockController.config.restartRequired, true); }); it("leaves the flag alone for an already loaded plugin", async function() { await install("restart_test"); assert.equal(mockController.config.restartRequired, false); }); it("leaves the flag alone for a pinned version of an already loaded plugin", async function() { await install("restart_test@1.0.0"); assert.equal(mockController.config.restartRequired, false); }); it("rejects when installs are disabled", async function() { mockController.config = new lib.ControllerConfig( "controller", { "controller.allow_plugin_install": false } ); await assert.rejects(install("new_plugin"), /Plugin installs are disabled/); assert.equal(mockController.config.restartRequired, false); }); }); describe(".handleInstanceCreateRequest()", function() { let mockController; beforeEach(function() { mockController = new MockController(); mockController.mockConfigEntries.set("controller.name", "Test"); mockController.instances = new InstanceManager(new lib.SubscribableDatastore(), mockController); }); async function create(config, cloneFromId) { await ControlConnection.prototype.handleInstanceCreateRequest.call( { _controller: mockController }, new lib.InstanceCreateRequest(config, cloneFromId), ); } it("rejects instance.assigned_host in the config", async function() { await assert.rejects( create({ "instance.id": 4001, "instance.name": "c1", "instance.assigned_host": 10 }), new lib.RequestError("instance.assigned_host must be set through the assign-host interface"), ); assert.equal(mockController.instances.has(4001), false); }); it("creates an unassigned clone of an assigned instance", async function() { await create({ "instance.id": 4001, "instance.name": "base" }); mockController.instances.getMutable(4001).config.set("instance.assigned_host", 10); await create({ "instance.id": 4002, "instance.name": "clone", "instance.assigned_host": null }, 4001); const clone = mockController.instances.get(4002); assert.equal(clone.config.get("instance.name"), "clone"); assert.equal(clone.config.get("instance.assigned_host"), null); }); }); describe("close cleanup", function() { let connector; let mockController; let connection; beforeEach(function() { connector = new MockConnector(addr("controller"), addr({ controlId: 1 })); connector._socket = {}; const transports = new Set(); mockController = { hooks: new ControllerHooks(new MockLogger()), router: null, _registeredRequests: new Map(), _fallbackedRequests: new Map(), _registeredEvents: new Map(), _snoopedEvents: new Map(), subscriptions: { unsubscribeLink() {} }, clusterLogger: { transports, add(transport) { transports.add(transport); }, remove(transport) { transports.delete(transport); }, }, debugEvents: new events.EventEmitter(), sendRequestToHostByInstanceId() {}, }; connection = new ControlConnection({ version: "test" }, connector, mockController, {}, 1); }); it("removes the log transport from clusterLogger", async function() { await connection.handleLogSetSubscriptionsRequest( new lib.LogSetSubscriptionsRequest(true, false, [], []) ); assert.equal(mockController.clusterLogger.transports.size, 1); connector.emit("close"); assert.equal(mockController.clusterLogger.transports.size, 0); assert.equal(connection.logTransport, null); }); it("stops following logs when the user loses permission", async function() { await connection.handleLogSetSubscriptionsRequest( new lib.LogSetSubscriptionsRequest(true, false, [], []) ); connection.user = { checkPermission() { throw new lib.PermissionError("Permission denied"); } }; connection.checkLogPermission(); assert.equal(mockController.clusterLogger.transports.size, 0); assert.equal(connection.logTransport, null); }); it("keeps following logs when the user has permission", async function() { await connection.handleLogSetSubscriptionsRequest( new lib.LogSetSubscriptionsRequest(true, false, [], []) ); connection.user = { checkPermission() {} }; connection.checkLogPermission(); assert.equal(mockController.clusterLogger.transports.size, 1); }); it("removes the debug dump listener after repeated requests", async function() { await connection.handleDebugDumpWsRequest(new lib.DebugDumpWsRequest()); await connection.handleDebugDumpWsRequest(new lib.DebugDumpWsRequest()); assert.equal(mockController.debugEvents.listenerCount("message"), 1); assert.equal(connector._socket.clusterio_ignore_dump, true); connector.emit("close"); assert.equal(mockController.debugEvents.listenerCount("message"), 0); }); }); describe(".handleUserDeleteRequest()", function() { let mockController; let nextId; beforeEach(function() { mockController = new MockController(); mockController.sendTo = () => {}; mockController.wsServer.controlConnections = new Map(); nextId = 1; }); function connect(name) { const connection = { _controller: mockController, user: mockController.users.getByName(name), connector: { terminated: false, terminate() { this.terminated = true; }, }, }; mockController.wsServer.controlConnections.set(nextId, connection); nextId += 1; return connection; } async function deleteUser(connection, name) { await ControlConnection.prototype.handleUserDeleteRequest.call( connection, new lib.UserDeleteRequest(name) ); } it("terminates connections of the deleted user", async function() { const admin = connect("test"); const player1 = connect("player"); const player2 = connect("player"); await deleteUser(admin, "player"); assert.equal(mockController.users.getByName("player"), undefined); assert.equal(player1.connector.terminated, true); assert.equal(player2.connector.terminated, true); assert.equal(admin.connector.terminated, false); }); it("terminates its own connection when deleting itself", async function() { const player = connect("player"); await deleteUser(player, "player"); assert.equal(player.connector.terminated, true); }); }); });