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, and // optionally instance id -> the name the controller has on record. function makeInstances(assignments, names = new Map()) { return { get(id) { if (!assignments.has(id)) { return undefined; } const host = assignments.get(id); return { config: { get(field) { if (field === "instance.assigned_host") { return host; } if (field === "instance.name") { return names.get(id); } return 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]]); const names = new Map([[1, "one"], [2, "two"]]); let warnings = []; let originalWarn; beforeEach(function() { warnings = []; originalWarn = lib.logger.warn; lib.logger.warn = msg => { warnings.push(msg); }; }); afterEach(function() { lib.logger.warn = originalWarn; }); // Handlers call other methods on this, so inherit the prototype. function makeConnection(hostId) { const logged = []; const ctx = Object.assign(Object.create(HostConnection.prototype), { info: { id: hostId, name: `host-${hostId}` }, _controller: { instances: makeInstances(assignments, names), clusterLogger: { log(entry) { logged.push(entry); } }, }, }); return [ctx, logged]; } function run(hostId, info) { const [ctx, logged] = makeConnection(hostId); 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", }]); assert.deepEqual(warnings, [], "a matching name must not warn"); }); it("corrects an instance name which does not match the controller", async function() { const logged = run(10, { level: "info", message: "hi", instance_id: 1, instance_name: "wrong" }); assert.deepEqual(logged, [{ level: "info", message: "hi", instance_id: 1, instance_name: "one", host_id: 10, host_name: "host-10", }]); assert.equal(warnings.length, 1, "a misaligned name must warn"); }); it("warns about a misaligned name again after five minutes", async function() { const [ctx, logged] = makeConnection(10); const info = { level: "info", message: "hi", instance_id: 1, instance_name: "wrong" }; ctx.handleLogMessageEvent(new lib.LogMessageEvent(info)); ctx.handleLogMessageEvent(new lib.LogMessageEvent(info)); assert.equal(warnings.length, 1, "the warning must not repeat immediately"); // Pretend the last warning happened just over five minutes ago. ctx._lastLogMessageWarnMs -= 5 * 60 * 1000 + 1; ctx.handleLogMessageEvent(new lib.LogMessageEvent(info)); assert.equal(warnings.length, 2, "the warning must resurface after the timeout"); assert.equal(logged.length, 3); assert.deepEqual(logged.map(e => e.instance_name), ["one", "one", "one"]); }); it("shares the warning suppression between bogus id and misaligned name", async function() { const [ctx, logged] = makeConnection(10); // Instance 2 is on host 20, so this line is bogus. ctx.handleLogMessageEvent(new lib.LogMessageEvent( { level: "info", message: "hi", instance_id: 2, instance_name: "two" } )); assert.equal(warnings.length, 1); ctx.handleLogMessageEvent(new lib.LogMessageEvent( { level: "info", message: "hi", instance_id: 1, instance_name: "wrong" } )); assert.equal(warnings.length, 1, "both warnings must share the suppression"); assert.equal(logged[1].instance_name, "one", "the name is still corrected while suppressed"); }); 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" }]); }); }); });