Files
factorio-clusterio/test/controller/HostConnection.test.js
T
bbassieandClaude Opus 5 cf4e85c6ee Guard forwarded log messages by instance assignment
Log messages share the root cause with the save details updates fixed in
the previous commit: the controller trusted host supplied instance
fields. LogMessageEvent only declares level and message in its schema,
but the Ajv instance is built without removeAdditional and the schema
does not set additionalProperties, so instance_id and instance_name sent
by a host survive validation and pass through fromJSON untouched. A host
could tag a log line with an instance assigned to another host and have
the controller store and serve it as that instance's output.

Lift the assignment check out of handleInstanceSaveDetailsUpdatesEvent
into a private method and use it from both handlers.

Strip the instance fields instead of dropping the line. A host racing a
reassignment does emit instance log lines for an instance the controller
has already moved, and dropping those loses real output exactly when a
migration is going wrong. The line is still genuine output from the host,
so it is recorded as a host level message, which grants the host nothing
it could not already log directly. Warn once per connection so a
misbehaving host cannot flood the log.

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

126 lines
4.8 KiB
JavaScript

import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { HostConnection } from "@clusterio/controller";
function makeSave(instanceId, name) {
return new lib.SaveDetails(instanceId, "file", name, 0, 0, false, false, 0, false);
}
// Mock saves datastore backed by a Map keyed on SaveDetails.id.
class MockSaves {
constructor(saves = []) {
this.saves = new Map(saves.map(s => [s.id, s]));
}
get(id) { return this.saves.get(id); }
values() { return this.saves.values(); }
setMany(saves) { for (const s of saves) { this.saves.set(s.id, s); } }
deleteMany(saves) { for (const s of saves) { this.saves.delete(s.id); } }
}
// Mock instances manager where instance id -> assigned host id.
function makeInstances(assignments) {
return {
get(id) {
if (!assignments.has(id)) {
return undefined;
}
const host = assignments.get(id);
return { config: { get: name => (name === "instance.assigned_host" ? host : undefined) } };
},
};
}
describe("controller/src/HostConnection", function() {
describe(".handleInstanceSaveDetailsUpdatesEvent()", function() {
// Instance 1 is on host 10, instance 2 is on host 20.
const assignments = new Map([[1, 10], [2, 20]]);
// Inherit the prototype, handlers call other methods on this.
function run(hostId, saves, event) {
const saveStore = new MockSaves(saves);
const ctx = Object.assign(Object.create(HostConnection.prototype), {
info: { id: hostId },
_controller: { instances: makeInstances(assignments), saves: saveStore },
});
ctx.handleInstanceSaveDetailsUpdatesEvent(event);
return saveStore;
}
it("applies updates for an instance assigned to this host", async function() {
const store = run(10, [], new lib.InstanceSaveDetailsUpdatesEvent([makeSave(1, "a.zip")], 1));
assert.deepEqual([...store.values()].map(s => s.id), ["1/a.zip"]);
});
it("sweeps missing saves for an instance assigned to this host", async function() {
const store = run(10, [makeSave(1, "old.zip")], new lib.InstanceSaveDetailsUpdatesEvent([], 1));
assert.deepEqual([...store.values()], []);
});
it("ignores injected saves for an instance on another host", async function() {
const store = run(30, [], new lib.InstanceSaveDetailsUpdatesEvent([makeSave(1, "HACKED.zip")]));
assert.deepEqual([...store.values()], [], "foreign save must not be stored");
});
it("ignores a wipe of an instance on another host", async function() {
const existing = makeSave(1, "keep.zip");
const store = run(30, [existing], new lib.InstanceSaveDetailsUpdatesEvent([], 1));
assert.deepEqual([...store.values()].map(s => s.id), ["1/keep.zip"], "existing save must survive");
});
it("ignores updates for a nonexistent instance", async function() {
const store = run(30, [], new lib.InstanceSaveDetailsUpdatesEvent([makeSave(999, "x.zip")]));
assert.deepEqual([...store.values()], []);
});
});
describe(".handleLogMessageEvent()", function() {
// Instance 1 is on host 10, instance 2 is on host 20.
const assignments = new Map([[1, 10], [2, 20]]);
function run(hostId, info) {
const logged = [];
const ctx = Object.assign(Object.create(HostConnection.prototype), {
info: { id: hostId, name: `host-${hostId}` },
_controller: {
instances: makeInstances(assignments),
clusterLogger: { log(entry) { logged.push(entry); } },
},
});
ctx.handleLogMessageEvent(new lib.LogMessageEvent(info));
return logged;
}
it("keeps instance fields for an instance assigned to this host", async function() {
const logged = run(10, { level: "info", message: "hello", instance_id: 1, instance_name: "one" });
assert.deepEqual(logged, [{
level: "info", message: "hello", instance_id: 1, instance_name: "one",
host_id: 10, host_name: "host-10",
}]);
});
it("logs host level messages unchanged", async function() {
const logged = run(10, { level: "info", message: "hello" });
assert.deepEqual(logged, [{ level: "info", message: "hello", host_id: 10, host_name: "host-10" }]);
});
it("strips instance fields claiming an instance on another host", async function() {
const logged = run(30, { level: "info", message: "hi", instance_id: 1, instance_name: "one" });
assert.deepEqual(logged, [{ level: "info", message: "hi", host_id: 30, host_name: "host-30" }]);
});
it("strips instance fields claiming a nonexistent instance", async function() {
const logged = run(30, { level: "info", message: "hi", instance_id: 999, instance_name: "ghost" });
assert.deepEqual(logged, [{ level: "info", message: "hi", host_id: 30, host_name: "host-30" }]);
});
it("overrides host fields sent by the host", async function() {
const logged = run(10, { level: "info", message: "hi", host_id: 99, host_name: "spoofed" });
assert.deepEqual(logged, [{ level: "info", message: "hi", host_id: 10, host_name: "host-10" }]);
});
});
});