Files
factorio-clusterio/test/controller/ControlConnection.test.js
T
bbassieandClaude Opus 5 fb71ee7282 Terminate control connections of deleted users
ControlConnection keeps the User object it authenticated with and checks
permissions against it. Deleting the user removed the record but left
its sessions open, so a deleted user kept all its permissions until the
WebSocket closed.

Repro: create "victim" with role 0 and connect as it. Another admin
sends UserDeleteRequest("victim"). Victim can still send
UserCreateRequest("mallory") and UserUpdateRolesRequest("mallory", [0]).

Terminate the deleted user's connections like revoke-token does. When
a user deletes itself, defer terminating the requesting connection so
the response is sent first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:30:45 +00:00

148 lines
4.7 KiB
JavaScript

import assert from "node:assert/strict";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { ControlConnection, ControllerHooks } from "@clusterio/controller";
import { MockConnector, MockController, MockLogger } from "../mock.js";
const addr = lib.Address.fromShorthand;
describe("controller/src/ControlConnection", function() {
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("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("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 after the handler returns", async function() {
const player = connect("player");
await deleteUser(player, "player");
assert.equal(player.connector.terminated, false);
await new Promise(resolve => setImmediate(resolve));
assert.equal(player.connector.terminated, true);
});
});
});