Files
factorio-clusterio/test/controller/ControlConnection.test.js
bbassieandClaude Opus 5.5 54e9340e61 Add UnavailableError and keep the PermissionError name
Host not connected, host shutting down and instance not running are
now UnavailableError (503) instead of ConflictError. The request error
migration notes move to a new 2.1 guide, since this ships in 2.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:03:13 +00:00

276 lines
9.6 KiB
JavaScript

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.ValidationError("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);
});
});
});