mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 15:24:52 +09:00
Merge remote-tracking branch 'origin/master' into fix/headless-export-icons
This commit is contained in:
114 files changed
+5338
-1165
No files matched your search
@@ -1,8 +1,11 @@
|
||||
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 {
|
||||
Controller, ControlConnection, ControllerHooks, HostConnection, HostRecord, InstanceManager, InstanceRecord,
|
||||
} from "@clusterio/controller";
|
||||
import { MockConnector, MockController, MockLogger } from "../mock.js";
|
||||
import "../setup_logging.js";
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
@@ -272,4 +275,164 @@ describe("controller/src/ControlConnection", function() {
|
||||
assert.equal(player.connector.terminated, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host management", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {ControlConnection} */
|
||||
let connection;
|
||||
let audits;
|
||||
let originalAudit;
|
||||
|
||||
beforeEach(function() {
|
||||
const config = new lib.ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
||||
controller = new Controller(lib.logger, [], config);
|
||||
const connector = new lib.VirtualConnector(addr("controller"), addr({ controlId: 1 }));
|
||||
const user = controller.users.getOrCreateUser("admin");
|
||||
connection = new ControlConnection({ version: "2.0.0" }, connector, controller, user, 1);
|
||||
audits = [];
|
||||
originalAudit = lib.logger.audit;
|
||||
lib.logger.audit = msg => { audits.push(msg); };
|
||||
});
|
||||
afterEach(function() {
|
||||
lib.logger.audit = originalAudit;
|
||||
});
|
||||
|
||||
describe(".handleHostRevokeRequest()", function() {
|
||||
let record;
|
||||
let broadcasts;
|
||||
|
||||
beforeEach(function() {
|
||||
record = new HostRecord(10, "host-10", "", new Map());
|
||||
controller.hosts.set(record);
|
||||
broadcasts = [];
|
||||
controller.subscriptions.broadcast = event => {
|
||||
if (event instanceof lib.HostUpdatesEvent) {
|
||||
broadcasts.push(...event.updates.map(host => ({ id: host.id, isDeleted: host.isDeleted })));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function assignInstance(id, name, hostId) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.set("instance.id", id);
|
||||
instanceConfig.set("instance.name", name);
|
||||
instanceConfig.set("instance.assigned_host", hostId);
|
||||
controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped"));
|
||||
}
|
||||
|
||||
async function revoke(hostId = 10) {
|
||||
await connection.handleHostRevokeRequest(new lib.HostRevokeRequest(hostId));
|
||||
}
|
||||
|
||||
it("deletes the host record", async function() {
|
||||
await revoke();
|
||||
assert.equal(controller.hosts.has(10), false);
|
||||
assert.deepEqual(broadcasts, [{ id: 10, isDeleted: true }]);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.match(audits[0], /User admin revoked host host-10 \(10\)/);
|
||||
});
|
||||
it("deletes the note of the host", async function() {
|
||||
controller.setNote("host", "10", "rack 3", "someone");
|
||||
controller.setNote("host", "11", "rack 4", "someone");
|
||||
await revoke();
|
||||
const note = controller.notes.get("host/10");
|
||||
assert.equal(note.isDeleted, true);
|
||||
assert.equal(note.content, "");
|
||||
assert.equal(note.updatedBy, "admin");
|
||||
assert.equal(controller.notes.get("host/11").isDeleted, false);
|
||||
});
|
||||
it("terminates the connection of the host", async function() {
|
||||
let terminated = false;
|
||||
controller.wsServer.hostConnections.set(10, { connector: { terminate() { terminated = true; } } });
|
||||
await revoke();
|
||||
assert.equal(terminated, true);
|
||||
});
|
||||
it("does not leave a host record behind when the connection closes", async function() {
|
||||
const connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
|
||||
connector.terminate = () => { connector.emit("close"); };
|
||||
const hostConnection = new HostConnection(
|
||||
new lib.RegisterHostData("", "2.0.0", 10, {}), connector, controller, "10.0.0.1"
|
||||
);
|
||||
controller.wsServer.hostConnections.set(10, hostConnection);
|
||||
assert.equal(controller.hosts.get(10).connected, true);
|
||||
|
||||
await revoke();
|
||||
assert.equal(controller.hosts.has(10), false, "host record was recreated");
|
||||
assert.deepEqual(broadcasts.at(-1), { id: 10, isDeleted: true });
|
||||
});
|
||||
it("unassigns the instances of the host without waiting for it", async function() {
|
||||
let closing = false;
|
||||
controller.wsServer.hostConnections.set(10, {
|
||||
connector: { terminate() { closing = true; }, get closing() { return closing; } },
|
||||
send: () => new Promise(() => {}),
|
||||
});
|
||||
assignInstance(101, "alpha", 10);
|
||||
assignInstance(102, "beta", 10);
|
||||
assignInstance(201, "gamma", 20);
|
||||
|
||||
await revoke();
|
||||
assert.equal(controller.hosts.has(10), false);
|
||||
for (const id of [101, 102]) {
|
||||
const instance = controller.instances.get(id);
|
||||
assert.equal(instance.config.get("instance.assigned_host"), null);
|
||||
assert.equal(instance.status, "unassigned");
|
||||
}
|
||||
assert.equal(controller.instances.get(201).config.get("instance.assigned_host"), 20);
|
||||
});
|
||||
it("removes the system metrics of the host", async function() {
|
||||
controller.systems.set(await lib.gatherSystemInfo(10, false, false, 0));
|
||||
controller.systems.set(await lib.gatherSystemInfo("controller", false, false, 0));
|
||||
await revoke();
|
||||
assert.equal(controller.systems.has(10), false);
|
||||
assert.equal(controller.systems.has("controller"), true);
|
||||
});
|
||||
it("throws NotFoundError for an unknown host", async function() {
|
||||
await assert.rejects(revoke(99), new lib.NotFoundError("Unknown host id (99)"));
|
||||
assert.equal(controller.hosts.has(10), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host pairing requests", function() {
|
||||
it("lists pending requests", async function() {
|
||||
const first = controller.hostPairing.create("Host A", "2.0.0", "10.0.0.1");
|
||||
const second = controller.hostPairing.create("Host B", "2.0.0", "10.0.0.2");
|
||||
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
|
||||
assert(list.every(details => details instanceof lib.HostPairingDetails));
|
||||
assert.deepEqual(list.map(details => details.code).sort(), [first.code, second.code].sort());
|
||||
assert.equal(list.find(details => details.code === first.code).name, "Host A");
|
||||
});
|
||||
it("leaves out expired, approved and rejected requests when listing", async function() {
|
||||
const expired = controller.hostPairing.create("Host A", "", "10.0.0.1");
|
||||
const approved = controller.hostPairing.create("Host B", "", "10.0.0.2");
|
||||
const rejected = controller.hostPairing.create("Host C", "", "10.0.0.3");
|
||||
const pending = controller.hostPairing.create("Host D", "", "10.0.0.4");
|
||||
controller.hostPairing._bySecret.get(expired.requestId).lastPollMs -= 60e3;
|
||||
controller.hostPairing.approve(approved.code, undefined, "admin");
|
||||
controller.hostPairing.reject(rejected.code, "admin");
|
||||
|
||||
const list = await connection.handleHostPairingListRequest(new lib.HostPairingListRequest());
|
||||
assert.deepEqual(list.map(details => details.code), [pending.code]);
|
||||
});
|
||||
it("approves a request and returns the new host id", async function() {
|
||||
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
|
||||
const hostId = await connection.handleHostPairingApproveRequest(
|
||||
new lib.HostPairingApproveRequest(code.toLowerCase().replace("-", ""))
|
||||
);
|
||||
assert.equal(typeof hostId, "number");
|
||||
assert.equal(controller.hosts.get(hostId).name, "Host A");
|
||||
assert.equal(controller.hostPairing.poll(requestId).hostId, hostId);
|
||||
assert(audits.some(msg => msg.startsWith(`User admin approved host Host A (${hostId})`)));
|
||||
});
|
||||
it("rejects a request", async function() {
|
||||
const { requestId, code } = controller.hostPairing.create("Host A", "", "10.0.0.1");
|
||||
await connection.handleHostPairingRejectRequest(new lib.HostPairingRejectRequest(code.toLowerCase()));
|
||||
assert.equal(controller.hostPairing.records.has(code), false);
|
||||
assert.throws(() => controller.hostPairing.poll(requestId), /Request was rejected/);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,75 @@ describe("controller/src/Controller", function() {
|
||||
assert(loaded.modPacks.size > 0, "default mod packs were not added");
|
||||
await fs.access(modsDirectory);
|
||||
});
|
||||
it("should start and stop with an existing database", async function() {
|
||||
this.timeout(10000);
|
||||
const databaseDirectory = path.join(dir, "database");
|
||||
await fs.mkdir(databaseDirectory);
|
||||
const records = {
|
||||
"hosts.json": [new HostRecord(5, "existing-host", controllerVersion, new Map(), true)],
|
||||
"instances.json": [
|
||||
new InstanceRecord(new InstanceConfig("controller", {
|
||||
"instance.id": 100, "instance.assigned_host": 5,
|
||||
}), "running"),
|
||||
new InstanceRecord(new InstanceConfig("controller", { "instance.id": 101 }), "unassigned"),
|
||||
],
|
||||
"mod-packs.json": [ModPack.fromJSON({ id: 7, name: "existing-pack" })],
|
||||
"users.json": [new UserRecord(0, "admin", new Set([0]), new Set(), true)],
|
||||
};
|
||||
for (const [file, content] of Object.entries(records)) {
|
||||
await fs.writeFile(path.join(databaseDirectory, file), JSON.stringify(content));
|
||||
}
|
||||
|
||||
// Port 0 means not configured, so ask the OS for a free one.
|
||||
const portProbe = http.createServer();
|
||||
portProbe.listen(0, "127.0.0.1");
|
||||
await events.once(portProbe, "listening");
|
||||
const port = portProbe.address().port;
|
||||
portProbe.close();
|
||||
await events.once(portProbe, "close");
|
||||
|
||||
const config = new ControllerConfig("controller", {
|
||||
"controller.version": controllerVersion,
|
||||
"controller.database_directory": databaseDirectory,
|
||||
"controller.mods_directory": path.join(dir, "mods"),
|
||||
"controller.http_port": port,
|
||||
"controller.bind_address": "127.0.0.1",
|
||||
});
|
||||
|
||||
const errors = [];
|
||||
const errorCapture = new lib.ConsoleTransport({
|
||||
level: "error",
|
||||
filter: info => {
|
||||
// pnpm build does not produce the production web bundle
|
||||
if (!info.message.includes("Missing main entry")) {
|
||||
errors.push(info.message);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
lib.logger.add(errorCapture);
|
||||
let loaded;
|
||||
try {
|
||||
loaded = await Controller.fromConfig(config, [], lib.logger);
|
||||
await loaded.start({ logDirectory: path.join(dir, "logs"), _: ["run"] });
|
||||
assert.deepEqual(
|
||||
[...loaded.instances.values()].map(instance => [instance.id, instance.status]),
|
||||
[[100, "unknown"], [101, "unassigned"]],
|
||||
);
|
||||
assert.equal(loaded.hosts.get(5).connected, false);
|
||||
assert.equal(loaded.modPacks.get(7).name, "existing-pack");
|
||||
assert(loaded.users.getByName("admin"), "existing user was not loaded");
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`);
|
||||
assert.equal(response.status, 200);
|
||||
await response.text();
|
||||
} finally {
|
||||
await loaded?.stop();
|
||||
lib.logger.remove(errorCapture);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(databaseDirectory, "instances.json"), "utf8"));
|
||||
assert.deepEqual(saved.map(instance => instance.config["instance.id"]), [100, 101]);
|
||||
});
|
||||
describe("with a plugin version change", function() {
|
||||
before(function() {
|
||||
addPluginConfigFields([{ name: "from_config_plugin", version: "1.2.0", instanceEntrypoint: "x" }]);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { HostConnection } from "@clusterio/controller";
|
||||
import { Controller, HostConnection, HostRecord } from "@clusterio/controller";
|
||||
import { MockConnector } from "../mock.js";
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
function makeSave(instanceId, name) {
|
||||
return new lib.SaveDetails(instanceId, "file", name, 0, 0, false, false, 0, false);
|
||||
@@ -40,6 +43,99 @@ function makeInstances(assignments, names = new Map()) {
|
||||
}
|
||||
|
||||
describe("controller/src/HostConnection", function() {
|
||||
describe("host record", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
let record;
|
||||
let connector;
|
||||
let updates;
|
||||
|
||||
beforeEach(function() {
|
||||
controller = new Controller(lib.logger, [], new lib.ControllerConfig("controller"));
|
||||
record = new HostRecord(10, "host-10", "1.0.0", new Map(), false, "", "203.0.113.1", 123);
|
||||
controller.hosts.set(record);
|
||||
connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
|
||||
updates = [];
|
||||
controller.hosts.on("update", changed => {
|
||||
updates.push(...changed.map(host => ({ id: host.id, name: host.name, isDeleted: host.isDeleted })));
|
||||
});
|
||||
});
|
||||
|
||||
function connect(id = 10) {
|
||||
return new HostConnection(
|
||||
new lib.RegisterHostData("", "2.0.0", id, {}), connector, controller, "10.0.0.1"
|
||||
);
|
||||
}
|
||||
|
||||
function revoke() {
|
||||
controller.hosts.delete(record);
|
||||
updates.length = 0;
|
||||
}
|
||||
|
||||
it("should refuse a host without a record", function() {
|
||||
assert.throws(() => connect(11), /Host 11 is not registered/);
|
||||
assert.equal(controller.hosts.has(11), false);
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
it("should update the existing record on connect", function() {
|
||||
const connection = connect();
|
||||
assert.equal(controller.hosts.get(10), record, "host record was replaced");
|
||||
assert.equal(connection.id, 10);
|
||||
assert.equal(record.connected, true);
|
||||
assert.equal(record.version, "2.0.0");
|
||||
assert.equal(record.remoteAddress, "10.0.0.1");
|
||||
assert.equal(record.name, "host-10");
|
||||
assert.equal(record.publicAddress, "203.0.113.1");
|
||||
assert.equal(record.tokenValidAfter, 123);
|
||||
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
|
||||
});
|
||||
it("should mark the host disconnected when the connection closes", function() {
|
||||
connect();
|
||||
updates.length = 0;
|
||||
connector.emit("close");
|
||||
assert.equal(controller.hosts.get(10), record);
|
||||
assert.equal(record.connected, false);
|
||||
assert.deepEqual(updates, [{ id: 10, name: "host-10", isDeleted: false }]);
|
||||
});
|
||||
it("should save info updates from the host", async function() {
|
||||
const connection = connect();
|
||||
updates.length = 0;
|
||||
await connection.handleHostInfoUpdateEvent(
|
||||
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
|
||||
);
|
||||
assert.equal(controller.hosts.get(10).name, "renamed");
|
||||
assert.equal(controller.hosts.get(10).publicAddress, "198.51.100.1");
|
||||
assert.deepEqual(updates, [{ id: 10, name: "renamed", isDeleted: false }]);
|
||||
});
|
||||
it("should not recreate the record of a revoked host when the connection closes", function() {
|
||||
connect();
|
||||
revoke();
|
||||
connector.emit("close");
|
||||
assert.equal(controller.hosts.has(10), false, "host record was recreated");
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
it("should not recreate the record of a revoked host on info updates", async function() {
|
||||
const connection = connect();
|
||||
revoke();
|
||||
await connection.handleHostInfoUpdateEvent(
|
||||
new lib.HostInfoUpdateEvent(new lib.HostInfoUpdate("renamed", "198.51.100.1"))
|
||||
);
|
||||
assert.equal(controller.hosts.has(10), false, "host record was recreated");
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
it("should not overwrite a newer record with the same id", function() {
|
||||
connect();
|
||||
revoke();
|
||||
const newer = new HostRecord(10, "newer", "", new Map());
|
||||
controller.hosts.set(newer);
|
||||
updates.length = 0;
|
||||
connector.emit("close");
|
||||
assert.equal(controller.hosts.get(10), newer);
|
||||
assert.equal(newer.name, "newer");
|
||||
assert.deepEqual(updates, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".handleInstanceSaveDetailsUpdatesEvent()", function() {
|
||||
// Instance 1 is on host 10, instance 2 is on host 20.
|
||||
const assignments = new Map([[1, 10], [2, 20]]);
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, HostPairingManager, HostPairingError, HostRecord } from "@clusterio/controller";
|
||||
import {
|
||||
pollTimeoutMs, maxPendingPerAddress, maxNameLength,
|
||||
} from "@clusterio/controller/dist/node/src/HostPairingManager.js";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
const codePattern = /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/;
|
||||
|
||||
function makeController() {
|
||||
const config = new lib.ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
||||
return new Controller(lib.logger, [], config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make crypto.randomInt return the given values before falling back to random ones
|
||||
*
|
||||
* @param {number[]} values - Values to return, in order.
|
||||
* @returns {() => void} function restoring crypto.randomInt.
|
||||
*/
|
||||
function stubRandomInt(values) {
|
||||
const original = crypto.randomInt;
|
||||
crypto.randomInt = (...args) => (values.length ? values.shift() : original(...args));
|
||||
return () => { crypto.randomInt = original; };
|
||||
}
|
||||
|
||||
function assertPairingError(fn, status, message) {
|
||||
assert.throws(fn, err => {
|
||||
assert(err instanceof HostPairingError, `expected HostPairingError, got ${err}`);
|
||||
assert.equal(err.status, status);
|
||||
if (message) {
|
||||
assert.equal(err.message, message);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
describe("controller/src/HostPairingManager", function() {
|
||||
|
||||
describe("HostPairingManager.validateName()", function() {
|
||||
it("should require a non-empty string", function() {
|
||||
for (const name of [undefined, null, 5, {}, "", " "]) {
|
||||
assertPairingError(() => HostPairingManager.validateName(name), 400, "Host name is required");
|
||||
}
|
||||
});
|
||||
it("should trim the name", function() {
|
||||
assert.equal(HostPairingManager.validateName(" Host A \t"), "Host A");
|
||||
});
|
||||
it("should limit the length of the name", function() {
|
||||
const longest = "x".repeat(maxNameLength);
|
||||
assert.equal(maxNameLength, 100);
|
||||
assert.equal(HostPairingManager.validateName(longest), longest);
|
||||
assert.equal(HostPairingManager.validateName(` ${longest} `), longest);
|
||||
assertPairingError(
|
||||
() => HostPairingManager.validateName(`${longest}x`),
|
||||
400, "Host name must be at most 100 characters",
|
||||
);
|
||||
});
|
||||
it("should refuse control and bidirectional override characters", function() {
|
||||
for (const name of ["x\u001b]0;title\u0007", "line\nfaked", "a\rb", "del\u007f", "c1\u009b", "x\u202ey"]) {
|
||||
assertPairingError(
|
||||
() => HostPairingManager.validateName(name), 400, "Host name may not contain control characters",
|
||||
);
|
||||
}
|
||||
});
|
||||
it("should allow other unicode characters", function() {
|
||||
for (const name of ["Fabrikk p\u00e5 \u00d8stlandet", "\u5de5\u5382 1", "box \u{1F44D}\u200d"]) {
|
||||
assert.equal(HostPairingManager.validateName(name), name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("class HostPairingManager", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {HostPairingManager} */
|
||||
let manager;
|
||||
let updates;
|
||||
let restoreRandomInt;
|
||||
|
||||
beforeEach(function() {
|
||||
controller = makeController();
|
||||
manager = controller.hostPairing;
|
||||
updates = [];
|
||||
manager.records.on("update", changed => {
|
||||
updates.push(...changed.map(details => ({ code: details.code, isDeleted: details.isDeleted })));
|
||||
});
|
||||
restoreRandomInt = undefined;
|
||||
});
|
||||
afterEach(function() {
|
||||
manager.stop();
|
||||
if (restoreRandomInt) {
|
||||
restoreRandomInt();
|
||||
}
|
||||
});
|
||||
|
||||
function internal(requestId) {
|
||||
return manager._bySecret.get(requestId);
|
||||
}
|
||||
|
||||
// Pretend the host last polled the request ms milliseconds earlier.
|
||||
function idle(requestId, ms) {
|
||||
internal(requestId).lastPollMs -= ms;
|
||||
}
|
||||
|
||||
describe(".createHost()", function() {
|
||||
it("should create a host record with a valid token", function() {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const result = manager.createHost("Host A");
|
||||
|
||||
assert.equal(result.name, "Host A");
|
||||
assert(Number.isInteger(result.hostId), "hostId is not an integer");
|
||||
assert(result.hostId >= 1 && result.hostId < 2 ** 31, `hostId ${result.hostId} out of range`);
|
||||
|
||||
const record = controller.hosts.get(result.hostId);
|
||||
assert(record instanceof HostRecord, "host record was not created");
|
||||
assert.equal(record.name, "Host A");
|
||||
assert.equal(record.connected, false);
|
||||
assert(record.tokenValidAfter >= before, "tokenValidAfter predates the call");
|
||||
assert(record.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
|
||||
|
||||
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
|
||||
assert.equal(payload.host, result.hostId);
|
||||
assert(payload.iat >= record.tokenValidAfter, "token is not valid for the new record");
|
||||
});
|
||||
it("should not reuse the id of an existing host", function() {
|
||||
controller.hosts.set(new HostRecord(5, "existing", "", new Map()));
|
||||
restoreRandomInt = stubRandomInt([5, 5, 6]);
|
||||
const result = manager.createHost("Host A");
|
||||
assert.equal(result.hostId, 6);
|
||||
assert.equal(controller.hosts.get(5).name, "existing");
|
||||
assert.equal(controller.hosts.get(6).name, "Host A");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".create()", function() {
|
||||
it("should create a pending request", function() {
|
||||
const before = Date.now();
|
||||
const { requestId, code } = manager.create("Host A", "2.0.0", "10.0.0.1");
|
||||
|
||||
assert.equal(typeof requestId, "string");
|
||||
assert(requestId.length >= 32, "requestId is too short to be a secret");
|
||||
assert.match(code, codePattern);
|
||||
assert.notEqual(requestId, code);
|
||||
|
||||
const details = manager.records.get(code);
|
||||
assert(details instanceof lib.HostPairingDetails);
|
||||
assert.equal(details.code, code);
|
||||
assert.equal(details.name, "Host A");
|
||||
assert.equal(details.version, "2.0.0");
|
||||
assert.equal(details.remoteAddress, "10.0.0.1");
|
||||
assert(details.createdAtMs >= before && details.createdAtMs <= Date.now());
|
||||
assert(details.updatedAtMs > 0);
|
||||
assert.equal(details.isDeleted, false);
|
||||
assert(!JSON.stringify(details).includes(requestId), "requestId is exposed to users");
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }]);
|
||||
});
|
||||
it("should create unique codes", function() {
|
||||
const codes = new Set();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const { code } = manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
||||
assert.match(code, codePattern);
|
||||
codes.add(code);
|
||||
}
|
||||
assert.equal(codes.size, 50);
|
||||
});
|
||||
it("should not reuse the code of a pending request", function() {
|
||||
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
assert.equal(manager.create("Host A", "", "10.0.0.1").code, "222-222");
|
||||
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
|
||||
});
|
||||
it("should not reuse the code of an approved request not yet picked up", function() {
|
||||
restoreRandomInt = stubRandomInt([0, 0, 0, 0, 0, 0, 1234, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]);
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
assert.equal(code, "222-222");
|
||||
assert.equal(manager.approve(code, undefined, "admin"), 1234);
|
||||
assert.equal(manager.create("Host B", "", "10.0.0.2").code, "333-333");
|
||||
});
|
||||
});
|
||||
|
||||
describe("limits", function() {
|
||||
it("should limit pending requests per address", function() {
|
||||
assert.equal(maxPendingPerAddress, 3);
|
||||
for (let i = 0; i < maxPendingPerAddress; i++) {
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
}
|
||||
assert.throws(() => manager.create("Host", "", "10.0.0.1"), err => {
|
||||
assert(err instanceof HostPairingError);
|
||||
assert.equal(err.status, 429);
|
||||
assert.equal(err.retryAfter, 30);
|
||||
assert.equal(err.message, "Too many pending requests from this address");
|
||||
return true;
|
||||
});
|
||||
// Other addresses are not affected
|
||||
manager.create("Host", "", "10.0.0.2");
|
||||
assert.equal(manager.records.size, maxPendingPerAddress + 1);
|
||||
});
|
||||
it("should count IPv4 mapped addresses as the IPv4 address", function() {
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
manager.create("Host", "", "::ffff:10.0.0.1");
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "::ffff:10.0.0.1"), 429);
|
||||
});
|
||||
it("should group IPv6 addresses by /64", function() {
|
||||
manager.create("Host", "", "2001:db8:0:1::1");
|
||||
manager.create("Host", "", "2001:db8:0:1::2");
|
||||
manager.create("Host", "", "2001:db8::1:ffff:0:0:3");
|
||||
assertPairingError(
|
||||
() => manager.create("Host", "", "2001:0db8:0000:0001:0000:0000:0000:0004"),
|
||||
429, "Too many pending requests from this address",
|
||||
);
|
||||
manager.create("Host", "", "2001:db8:0:2::1");
|
||||
});
|
||||
it("should free the slot when a request is no longer pending", function() {
|
||||
const first = manager.create("Host", "", "10.0.0.1");
|
||||
const second = manager.create("Host", "", "10.0.0.1");
|
||||
const third = manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
||||
|
||||
manager.cancel(first.requestId);
|
||||
const fourth = manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
||||
|
||||
// Approved and rejected results waiting to be picked up do not count
|
||||
manager.approve(second.code, undefined, "admin");
|
||||
manager.reject(third.code, "admin");
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
manager.create("Host", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.create("Host", "", "10.0.0.1"), 429);
|
||||
assert(manager.records.has(fourth.code));
|
||||
});
|
||||
it("should limit the total number of pending requests", function() {
|
||||
const maxPending = controller.config.get("controller.host_pairing_max_pending");
|
||||
assert.equal(maxPending, 50);
|
||||
for (let i = 0; i < maxPending; i++) {
|
||||
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
||||
}
|
||||
assert.throws(() => manager.create("Host", "", "10.2.0.1"), err => {
|
||||
assert(err instanceof HostPairingError);
|
||||
assert.equal(err.status, 429);
|
||||
assert.equal(err.retryAfter, 30);
|
||||
assert.equal(err.message, "Too many pending requests");
|
||||
return true;
|
||||
});
|
||||
assert.equal(manager.records.size, maxPending);
|
||||
});
|
||||
it("should not limit the total number of pending requests when set to 0", function() {
|
||||
controller.config.set("controller.host_pairing_max_pending", 0);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
manager.create(`Host ${i}`, "", `10.1.${i}.1`);
|
||||
}
|
||||
assert.equal(manager.records.size, 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".poll()", function() {
|
||||
it("should return null while pending", function() {
|
||||
const { requestId } = manager.create("Host A", "", "10.0.0.1");
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
});
|
||||
it("should throw 404 for an unknown request", function() {
|
||||
assertPairingError(() => manager.poll("invalid"), 404, "Request not found");
|
||||
});
|
||||
it("should not accept the code in place of the request id", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
assertPairingError(() => manager.poll(code), 404);
|
||||
});
|
||||
it("should hand over an approved result once", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
const result = manager.poll(requestId);
|
||||
assert.deepEqual(Object.keys(result).sort(), ["hostId", "name", "token"]);
|
||||
assert.equal(result.hostId, hostId);
|
||||
assert.equal(result.name, "Host A");
|
||||
const payload = jwt.verify(result.token, controller.authSecret, { audience: "host" });
|
||||
assert.equal(payload.host, hostId);
|
||||
|
||||
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
|
||||
assert.equal(manager._byCode.has(code), false);
|
||||
});
|
||||
it("should report a rejection once", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
assertPairingError(() => manager.poll(requestId), 403, "Request was rejected");
|
||||
assertPairingError(() => manager.poll(requestId), 404, "Request not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".cancel()", function() {
|
||||
it("should remove a pending request", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.cancel(requestId);
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
||||
});
|
||||
it("should ignore unknown requests", function() {
|
||||
manager.cancel("invalid");
|
||||
});
|
||||
it("should not throw away an approved result", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
manager.cancel(requestId);
|
||||
assert.equal(manager.poll(requestId).hostId, hostId);
|
||||
});
|
||||
it("should not throw away a rejection", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
manager.cancel(requestId);
|
||||
assertPairingError(() => manager.poll(requestId), 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".approve()", function() {
|
||||
it("should create the host with the requested name", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
assert.equal(controller.hosts.get(hostId).name, "Host A");
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
});
|
||||
it("should accept codes in any case and without the dash", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code.toLowerCase().replace("-", ""), undefined, "admin");
|
||||
assert(controller.hosts.has(hostId));
|
||||
});
|
||||
it("should use and trim the name given", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, " Renamed ", "admin");
|
||||
assert.equal(controller.hosts.get(hostId).name, "Renamed");
|
||||
assert.equal(manager.poll(requestId).name, "Renamed");
|
||||
});
|
||||
it("should reject an invalid name and keep the request pending", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
assert.throws(
|
||||
() => manager.approve(code, "", "admin"),
|
||||
new lib.ValidationError("Host name is required"),
|
||||
);
|
||||
assert.throws(
|
||||
() => manager.approve(code, "x".repeat(maxNameLength + 1), "admin"),
|
||||
new lib.ValidationError("Host name must be at most 100 characters"),
|
||||
);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
manager.approve(code, undefined, "admin");
|
||||
assert.equal(controller.hosts.size, 1);
|
||||
});
|
||||
it("should throw NotFoundError for an unknown code", function() {
|
||||
assert.throws(
|
||||
() => manager.approve("ZZZ-ZZZ", undefined, "admin"),
|
||||
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
|
||||
);
|
||||
});
|
||||
it("should only approve a request once", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.approve(code, undefined, "admin");
|
||||
assert.throws(() => manager.approve(code, undefined, "admin"), lib.NotFoundError);
|
||||
assert.equal(controller.hosts.size, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".reject()", function() {
|
||||
it("should remove the request from the pending list", function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code.toLowerCase(), "admin");
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
});
|
||||
it("should throw NotFoundError for an unknown code", function() {
|
||||
assert.throws(
|
||||
() => manager.reject("ZZZ-ZZZ", "admin"),
|
||||
new lib.NotFoundError("No pending host request with code ZZZ-ZZZ"),
|
||||
);
|
||||
});
|
||||
it("should not reject an approved request", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
||||
assert.equal(manager.poll(requestId).hostId, hostId);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".prune()", function() {
|
||||
let warnings;
|
||||
let originalWarn;
|
||||
beforeEach(function() {
|
||||
warnings = [];
|
||||
originalWarn = lib.logger.warn;
|
||||
lib.logger.warn = msg => { warnings.push(msg); };
|
||||
});
|
||||
afterEach(function() {
|
||||
lib.logger.warn = originalWarn;
|
||||
});
|
||||
|
||||
it("should expire a request the host stopped polling", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
idle(requestId, pollTimeoutMs + 1);
|
||||
manager.prune();
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assert.deepEqual(updates, [{ code, isDeleted: false }, { code, isDeleted: true }]);
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.throws(() => manager.reject(code, "admin"), lib.NotFoundError);
|
||||
});
|
||||
it("should keep a request that is still polled", function() {
|
||||
assert.equal(pollTimeoutMs, 30e3);
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
idle(requestId, pollTimeoutMs - 1000);
|
||||
manager.prune();
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
idle(requestId, pollTimeoutMs - 1000);
|
||||
manager.prune();
|
||||
assert(manager.records.has(code));
|
||||
});
|
||||
it("should expire a request that has been pending for too long", function() {
|
||||
const timeoutMs = controller.config.get("controller.host_pairing_timeout") * 1000;
|
||||
assert.equal(timeoutMs, 60 * 60e3);
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
internal(requestId).details.createdAtMs -= timeoutMs - 1000;
|
||||
manager.prune();
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
internal(requestId).details.createdAtMs -= 1001;
|
||||
manager.prune();
|
||||
assert.equal(manager.records.has(code), false);
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
});
|
||||
it("should not limit how long a request is pending when the timeout is 0", function() {
|
||||
controller.config.set("controller.host_pairing_timeout", 0);
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
internal(requestId).details.createdAtMs -= 365 * 24 * 60 * 60e3;
|
||||
manager.prune();
|
||||
assert(manager.records.has(code));
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
});
|
||||
it("should drop and warn about an approved host never picked up", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const hostId = manager.approve(code, undefined, "admin");
|
||||
idle(requestId, pollTimeoutMs + 1);
|
||||
manager.prune();
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], new RegExp(`Approved host Host A \\(${hostId}\\) was never picked up`));
|
||||
// The host stays until revoked
|
||||
assert(controller.hosts.has(hostId));
|
||||
});
|
||||
it("should drop a rejection never picked up", function() {
|
||||
const { requestId, code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
idle(requestId, pollTimeoutMs + 1);
|
||||
manager.prune();
|
||||
assertPairingError(() => manager.poll(requestId), 404);
|
||||
assert.equal(warnings.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Controller integration", function() {
|
||||
it("should broadcast changes to pending requests", function() {
|
||||
const broadcasts = [];
|
||||
controller.subscriptions.broadcast = event => {
|
||||
if (event instanceof lib.HostPairingUpdatesEvent) {
|
||||
broadcasts.push(event.updates.map(details => [details.code, details.isDeleted]));
|
||||
}
|
||||
};
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
manager.reject(code, "admin");
|
||||
assert.deepEqual(broadcasts, [[[code, false]], [[code, true]]]);
|
||||
});
|
||||
it("should send pending requests to new subscribers", async function() {
|
||||
const { code } = manager.create("Host A", "", "10.0.0.1");
|
||||
const event = await controller.handleHostPairingSubscription(
|
||||
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, 0)
|
||||
);
|
||||
assert(event instanceof lib.HostPairingUpdatesEvent);
|
||||
assert.deepEqual(event.updates.map(details => details.code), [code]);
|
||||
|
||||
const lastUpdate = manager.records.get(code).updatedAtMs;
|
||||
assert.equal(await controller.handleHostPairingSubscription(
|
||||
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true, lastUpdate)
|
||||
), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+126
-1
@@ -1,7 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
import { Controller, WsServer } from "@clusterio/controller";
|
||||
import jwt from "jsonwebtoken";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, HostRecord, WsServer } from "@clusterio/controller";
|
||||
import { ControllerConfig } from "@clusterio/lib";
|
||||
import packageJson from "@clusterio/controller/package.json" with { type: "json" };
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
class MockWebSocket extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.sent = [];
|
||||
this.closed = null;
|
||||
}
|
||||
|
||||
send(message) {
|
||||
this.sent.push(JSON.parse(message));
|
||||
}
|
||||
|
||||
close(code, reason) {
|
||||
this.closed = { code, reason };
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.emit("close", 1006, "");
|
||||
}
|
||||
}
|
||||
|
||||
describe("controller/src/WsServer", function() {
|
||||
describe("class WsServer", function() {
|
||||
@@ -88,4 +113,104 @@ describe("controller/src/WsServer", function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(".registerHost()", function() {
|
||||
const secret = Buffer.from("TestSecretDoNotUse");
|
||||
const req = { socket: { remoteAddress: "10.0.0.1", remoteFamily: "IPv4" }, headers: {} };
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {WsServer} */
|
||||
let wsServer;
|
||||
let socket;
|
||||
beforeEach(function() {
|
||||
const config = new ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", secret.toString("base64"));
|
||||
controller = new Controller(lib.logger, [], config);
|
||||
wsServer = controller.wsServer;
|
||||
socket = new MockWebSocket();
|
||||
});
|
||||
afterEach(function() {
|
||||
for (const connection of wsServer.hostConnections.values()) {
|
||||
connection.connector.terminate();
|
||||
}
|
||||
});
|
||||
|
||||
function addHost(id, tokenValidAfter = 0) {
|
||||
const record = new HostRecord(id, `host-${id}`, "", new Map(), false, "", "203.0.113.1", tokenValidAfter);
|
||||
controller.hosts.set(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function register(token, id = 5) {
|
||||
await wsServer.registerHost(new lib.RegisterHostData(token, packageJson.version, id, {}), socket, req);
|
||||
}
|
||||
|
||||
function assertRejected(reason) {
|
||||
assert.deepEqual(socket.closed, { code: lib.ConnectionClosed.Unauthorized, reason });
|
||||
assert.equal(socket.closed.code, 3000);
|
||||
assert.equal(wsServer.hostConnections.size, 0);
|
||||
assert(!socket.sent.some(message => message.type === "ready"), "ready was sent");
|
||||
}
|
||||
|
||||
it("should reject a validly signed token for a host that is not registered", async function() {
|
||||
await register(controller.generateHostToken(5));
|
||||
assertRejected(
|
||||
"Authentication failed: host is not registered, start the host again to request to join the cluster"
|
||||
);
|
||||
assert.equal(controller.hosts.has(5), false, "host record was created");
|
||||
});
|
||||
it("should accept a registered host", async function() {
|
||||
const record = addHost(5);
|
||||
await register(controller.generateHostToken(5));
|
||||
assert.equal(socket.closed, null);
|
||||
assert(socket.sent.some(message => message.type === "ready"), "ready was not sent");
|
||||
assert(wsServer.hostConnections.has(5));
|
||||
|
||||
assert.equal(controller.hosts.size, 1);
|
||||
assert.equal(controller.hosts.get(5), record, "host record was replaced");
|
||||
assert.equal(record.connected, true);
|
||||
assert.equal(record.version, packageJson.version);
|
||||
assert.equal(record.remoteAddress, "10.0.0.1");
|
||||
assert.equal(record.name, "host-5");
|
||||
assert.equal(record.publicAddress, "203.0.113.1");
|
||||
|
||||
wsServer.hostConnections.get(5).connector.terminate();
|
||||
assert.equal(wsServer.hostConnections.has(5), false);
|
||||
assert.equal(controller.hosts.get(5).connected, false);
|
||||
});
|
||||
it("should reject a token issued before tokenValidAfter", async function() {
|
||||
const token = controller.generateHostToken(5);
|
||||
addHost(5, Math.floor(Date.now() / 1000) + 60);
|
||||
await register(token);
|
||||
assertRejected("Authentication failed: invalid token");
|
||||
});
|
||||
it("should accept a token issued at tokenValidAfter", async function() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
addHost(5, now);
|
||||
await register(jwt.sign({ aud: "host", host: 5, iat: now }, secret));
|
||||
assert.equal(socket.closed, null);
|
||||
assert(wsServer.hostConnections.has(5));
|
||||
});
|
||||
it("should reject a host revoked while its previous connection closes", async function() {
|
||||
const record = addHost(5);
|
||||
wsServer.hostConnections.set(5, {
|
||||
remoteAddress: "10.0.0.1",
|
||||
async disconnect() {
|
||||
controller.hosts.delete(record);
|
||||
wsServer.hostConnections.delete(5);
|
||||
},
|
||||
});
|
||||
await register(controller.generateHostToken(5));
|
||||
assertRejected("Authentication failed: host was revoked");
|
||||
assert.equal(controller.hosts.has(5), false, "host record was recreated");
|
||||
});
|
||||
it("should reject hosts in recovery mode", async function() {
|
||||
addHost(5);
|
||||
controller.recoveryMode = true;
|
||||
await register(controller.generateHostToken(5));
|
||||
assert.deepEqual(socket.closed, {
|
||||
code: lib.ConnectionClosed.RecoveryMode,
|
||||
reason: "Connection Refused: Controller in recovery mode",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+299
-17
@@ -1,9 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
import FormData from "form-data";
|
||||
import http from "node:http";
|
||||
|
||||
import { wait, expositionContentType } from "@clusterio/lib";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { SubscribableDatastore, wait, expositionContentType } from "@clusterio/lib";
|
||||
import { Controller, HostPairingManager, HostRecord } from "@clusterio/controller";
|
||||
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
|
||||
import * as mock from "../mock.js";
|
||||
|
||||
@@ -211,6 +213,291 @@ describe("controller/src/routes", function() {
|
||||
assert.deepEqual(data, { name: "Test Cluster" });
|
||||
});
|
||||
});
|
||||
describe("/api/host-pairing", function() {
|
||||
let endpoint, adminToken, playerToken;
|
||||
beforeEach(function() {
|
||||
endpoint = `http://localhost:${port}/api/host-pairing`;
|
||||
// The mock controller lacks what pairing needs, use the real implementations.
|
||||
controller.hosts = new SubscribableDatastore();
|
||||
controller.hostPairing = new HostPairingManager(controller);
|
||||
controller.generateHostToken = Controller.prototype.generateHostToken;
|
||||
controller.verifyHostToken = Controller.prototype.verifyHostToken;
|
||||
controller.wsServer.remoteAddr = req => req.socket.remoteAddress;
|
||||
adminToken = controller.users.signUserToken(controller.users.getByName("test"));
|
||||
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
|
||||
});
|
||||
|
||||
function post(path, body, headers = {}) {
|
||||
return fetch(`${endpoint}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: typeof body === "string" || body === undefined ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function assertResponse(response, status, body) {
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
if (body !== undefined) {
|
||||
assert.deepEqual(await response.json(), body);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRequest(name = "Host A") {
|
||||
const response = await post("", { name });
|
||||
assert.equal(response.status, 202);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function check(headers) {
|
||||
return fetch(`${endpoint}/check`, { headers });
|
||||
}
|
||||
|
||||
function assertNothingCreated() {
|
||||
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
|
||||
assert.equal(controller.hosts.size, 0, "a host was created");
|
||||
}
|
||||
|
||||
describe("POST /api/host-pairing", function() {
|
||||
it("should create a pending request", async function() {
|
||||
const response = await post("", { name: "Host A", version: "2.0.0" });
|
||||
assert.equal(response.status, 202);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.deepEqual(Object.keys(body).sort(), ["code", "requestId"]);
|
||||
assert.match(body.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
|
||||
|
||||
const details = controller.hostPairing.records.get(body.code);
|
||||
assert.equal(details.name, "Host A");
|
||||
assert.equal(details.version, "2.0.0");
|
||||
assert.match(details.remoteAddress, /127\.0\.0\.1|::1/);
|
||||
assert.equal(controller.hosts.size, 0);
|
||||
});
|
||||
it("should trim the name and limit the version", async function() {
|
||||
let { code } = await (await post("", { name: " Host A ", version: "x".repeat(100) })).json();
|
||||
let details = controller.hostPairing.records.get(code);
|
||||
assert.equal(details.name, "Host A");
|
||||
assert.equal(details.version, "x".repeat(64));
|
||||
|
||||
({ code } = await (await post("", { name: "Host B", version: 5 })).json());
|
||||
details = controller.hostPairing.records.get(code);
|
||||
assert.equal(details.version, "");
|
||||
});
|
||||
it("should respond with 400 for a missing or invalid name", async function() {
|
||||
const required = { error: "Host name is required" };
|
||||
await assertResponse(await post("", {}), 400, required);
|
||||
await assertResponse(await post("", { name: "" }), 400, required);
|
||||
await assertResponse(await post("", { name: " " }), 400, required);
|
||||
await assertResponse(await post("", { name: 5 }), 400, required);
|
||||
await assertResponse(await post("", undefined), 400, required);
|
||||
await assertResponse(await post("", "name=Host", { "Content-Type": "text/plain" }), 400, required);
|
||||
await assertResponse(
|
||||
await post("", { name: "x".repeat(101) }),
|
||||
400, { error: "Host name must be at most 100 characters" },
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 400 for malformed JSON", async function() {
|
||||
controller.app.set("env", "test"); // Silence the default error handler
|
||||
const response = await post("", "{\"name\":");
|
||||
assert.equal(response.status, 400);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 413 for a body over 4kb", async function() {
|
||||
controller.app.set("env", "test"); // Silence the default error handler
|
||||
const response = await post("", { name: "x".repeat(5000) });
|
||||
assert.equal(response.status, 413);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 429 and Retry-After when limited", async function() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createRequest();
|
||||
}
|
||||
const response = await post("", { name: "Host A" });
|
||||
assert.equal(response.headers.get("retry-after"), "30");
|
||||
await assertResponse(response, 429, { error: "Too many pending requests from this address" });
|
||||
assert.equal(controller.hostPairing.records.size, 3);
|
||||
});
|
||||
it("should respond with 503 in recovery mode", async function() {
|
||||
controller.recoveryMode = true;
|
||||
const recovery = { error: "Controller is in recovery mode" };
|
||||
await assertResponse(await post("", { name: "Host A" }), 503, recovery);
|
||||
await assertResponse(
|
||||
await post("", { name: "Host A" }, { "Authorization": `Bearer ${adminToken}` }), 503, recovery
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should approve directly with the token of a user allowed to approve hosts", async function() {
|
||||
for (const headers of [
|
||||
{ "Authorization": `Bearer ${adminToken}` },
|
||||
{ "Authorization": `bearer ${adminToken}` },
|
||||
{ "X-Access-Token": adminToken },
|
||||
]) {
|
||||
const response = await post("", { name: "Host A" }, headers);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
|
||||
assert.equal(body.name, "Host A");
|
||||
assert.equal(controller.hosts.get(body.hostId).name, "Host A");
|
||||
const payload = jwt.verify(body.token, controller.authSecret, { audience: "host" });
|
||||
assert.equal(payload.host, body.hostId);
|
||||
}
|
||||
assert.equal(controller.hosts.size, 3);
|
||||
assert.equal(controller.hostPairing.records.size, 0, "a pending request was created");
|
||||
});
|
||||
it("should validate the name before approving directly", async function() {
|
||||
await assertResponse(
|
||||
await post("", {}, { "Authorization": `Bearer ${adminToken}` }),
|
||||
400, { error: "Host name is required" },
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 403 for a user not allowed to approve hosts", async function() {
|
||||
await assertResponse(
|
||||
await post("", { name: "Host A" }, { "Authorization": `Bearer ${playerToken}` }),
|
||||
403, { error: "User player is not allowed to approve hosts" },
|
||||
);
|
||||
assertNothingCreated();
|
||||
});
|
||||
it("should respond with 401 for an invalid token without creating a request", async function() {
|
||||
// The admin user's tokens are revoked after signing this one
|
||||
const revokedToken = adminToken;
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
|
||||
user.saveRecord();
|
||||
controller.hosts.set(new HostRecord(5, "five", "", new Map()));
|
||||
const hostToken = controller.generateHostToken(5);
|
||||
controller.hosts.delete(controller.hosts.get(5));
|
||||
|
||||
for (const headers of [
|
||||
{ "Authorization": "Bearer invalid" },
|
||||
{ "X-Access-Token": "invalid" },
|
||||
{ "Authorization": `Bearer ${controller.users.signUserToken({ id: "nobody" })}` },
|
||||
{ "Authorization": `Bearer ${revokedToken}` },
|
||||
{ "Authorization": `Bearer ${hostToken}` },
|
||||
]) {
|
||||
await assertResponse(
|
||||
await post("", { name: "Host A" }, headers), 401, { error: "Invalid user token" }
|
||||
);
|
||||
}
|
||||
assertNothingCreated();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/host-pairing/poll", function() {
|
||||
it("should respond with 202 while pending", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
|
||||
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
|
||||
});
|
||||
it("should hand over the result of an approved request once", async function() {
|
||||
const { requestId, code } = await createRequest();
|
||||
const hostId = controller.hostPairing.approve(code, "Renamed", "test");
|
||||
|
||||
const response = await post("/poll", { requestId });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
const body = await response.json();
|
||||
assert.deepEqual(Object.keys(body).sort(), ["hostId", "name", "token"]);
|
||||
assert.equal(body.hostId, hostId);
|
||||
assert.equal(body.name, "Renamed");
|
||||
assert.equal(jwt.verify(body.token, controller.authSecret, { audience: "host" }).host, hostId);
|
||||
|
||||
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
|
||||
});
|
||||
it("should respond with 403 once for a rejected request", async function() {
|
||||
const { requestId, code } = await createRequest();
|
||||
controller.hostPairing.reject(code, "test");
|
||||
await assertResponse(await post("/poll", { requestId }), 403, { error: "Request was rejected" });
|
||||
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
|
||||
});
|
||||
it("should respond with 404 for an unknown request", async function() {
|
||||
const { code } = await createRequest();
|
||||
const notFound = { error: "Request not found" };
|
||||
await assertResponse(await post("/poll", { requestId: "invalid" }), 404, notFound);
|
||||
await assertResponse(await post("/poll", { requestId: code }), 404, notFound);
|
||||
});
|
||||
it("should respond with 400 without a requestId in the body", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
const required = { error: "requestId is required" };
|
||||
await assertResponse(await post("/poll", {}), 400, required);
|
||||
await assertResponse(await post("/poll", { requestId: 5 }), 400, required);
|
||||
await assertResponse(await post(`/poll?requestId=${encodeURIComponent(requestId)}`, {}), 400, required);
|
||||
});
|
||||
it("should respond with 503 in recovery mode", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
controller.recoveryMode = true;
|
||||
await assertResponse(
|
||||
await post("/poll", { requestId }), 503, { error: "Controller is in recovery mode" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/host-pairing/cancel", function() {
|
||||
it("should cancel a pending request", async function() {
|
||||
const { requestId, code } = await createRequest();
|
||||
await assertResponse(await post("/cancel", { requestId }), 204);
|
||||
assert.equal(controller.hostPairing.records.has(code), false);
|
||||
await assertResponse(await post("/poll", { requestId }), 404, { error: "Request not found" });
|
||||
});
|
||||
it("should respond with 204 for an unknown request", async function() {
|
||||
const { code } = await createRequest();
|
||||
await assertResponse(await post("/cancel", { requestId: "invalid" }), 204);
|
||||
await assertResponse(await post("/cancel", { requestId: code }), 204);
|
||||
assert(controller.hostPairing.records.has(code), "cancelled by the code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("request ids", function() {
|
||||
it("should not be accepted in the URL", async function() {
|
||||
const { requestId } = await createRequest();
|
||||
const id = encodeURIComponent(requestId);
|
||||
for (const [method, path] of [
|
||||
["GET", `/poll?requestId=${id}`],
|
||||
["GET", `/poll/${id}`],
|
||||
["POST", `/poll/${id}`],
|
||||
["POST", `/${id}`],
|
||||
["GET", `/${id}`],
|
||||
["POST", `/cancel/${id}`],
|
||||
]) {
|
||||
const response = await fetch(`${endpoint}${path}`, { method });
|
||||
assert.equal(response.status, 404, `${method} ${path}`);
|
||||
}
|
||||
await assertResponse(await post("/poll", { requestId }), 202, { status: "pending" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/host-pairing/check", function() {
|
||||
it("should respond with 200 for a valid host token", async function() {
|
||||
const { hostId, token } = controller.hostPairing.createHost("Host A");
|
||||
for (const headers of [
|
||||
{ "Authorization": `Bearer ${token}` },
|
||||
{ "X-Access-Token": token },
|
||||
]) {
|
||||
await assertResponse(await check(headers), 200, { valid: true });
|
||||
}
|
||||
assert(controller.hosts.has(hostId));
|
||||
});
|
||||
it("should respond with 200 for a host token from before the slave rename", async function() {
|
||||
const { hostId } = controller.hostPairing.createHost("Host A");
|
||||
const token = jwt.sign({ aud: "slave", slave: hostId }, controller.authSecret);
|
||||
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
|
||||
});
|
||||
it("should respond with 401 without a valid host token", async function() {
|
||||
controller.hostPairing.createHost("Host A");
|
||||
assert.equal((await check({})).status, 401);
|
||||
assert.equal((await check({ "Authorization": "Bearer invalid" })).status, 401);
|
||||
assert.equal((await check({ "Authorization": `Bearer ${adminToken}` })).status, 401);
|
||||
});
|
||||
it("should respond with 401 once the host is revoked", async function() {
|
||||
const { hostId, token } = controller.hostPairing.createHost("Host A");
|
||||
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
|
||||
controller.hosts.delete(controller.hosts.get(hostId));
|
||||
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("/api/upload-save", function() {
|
||||
let endpoint, testToken;
|
||||
beforeEach(function() {
|
||||
@@ -308,27 +595,25 @@ describe("controller/src/routes", function() {
|
||||
let form;
|
||||
form = new FormData();
|
||||
form.append("instance_id", "123");
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "text/plain" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "text/plain" }), "test.zip");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { request_errors: ["invalid file Content-Type"] });
|
||||
form = new FormData();
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
|
||||
form.append("instance_id", "123");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(
|
||||
@@ -336,14 +621,13 @@ describe("controller/src/routes", function() {
|
||||
);
|
||||
form = new FormData();
|
||||
form.append("instance_id", "123");
|
||||
form.append("file", "totally a zip file", { filename: "test.txt", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.txt");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(
|
||||
@@ -351,14 +635,13 @@ describe("controller/src/routes", function() {
|
||||
);
|
||||
form = new FormData();
|
||||
form.append("instance_id", "invalid");
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(
|
||||
@@ -410,14 +693,13 @@ describe("controller/src/routes", function() {
|
||||
let form;
|
||||
form = new FormData();
|
||||
form.append("instance_id", "123");
|
||||
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
|
||||
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
|
||||
response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Access-Token": testToken,
|
||||
...form.getHeaders(),
|
||||
},
|
||||
body: form.getBuffer(),
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
|
||||
|
||||
+110
@@ -5,6 +5,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { escapeArg } from "../packages/create/escape_arg.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const installer = path.resolve(import.meta.dirname, "../packages/create/create.js");
|
||||
const discoveryUrl = new URL("../packages/lib/dist/node/src/load_plugin_list.js", import.meta.url).href;
|
||||
@@ -123,4 +125,112 @@ describe("create installer CLI", function() {
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("host setup", function() {
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const hostBin = path.join("node_modules", ".bin", `clusteriohost${scriptExt}`);
|
||||
const controllerBin = path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`);
|
||||
const hostSet = (field, value) => `${hostBin} config set ${field} ${escapeArg(String(value))}`;
|
||||
const controllerUrl = "http://controller.example.com:8080/";
|
||||
|
||||
function run(args, env = {}) {
|
||||
// Record the commands for npm and the clusterio CLIs instead of running them.
|
||||
const mockExec = `
|
||||
import childProcess from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { PassThrough } from "node:stream";
|
||||
childProcess.execFile = (command, options, callback) => {
|
||||
fs.appendFileSync(process.env.CREATE_TEST_COMMANDS, JSON.stringify(command) + "\\n");
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
queueMicrotask(() => {
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
callback(null, "", "");
|
||||
});
|
||||
return { stdout, stderr };
|
||||
};
|
||||
`;
|
||||
return execFileAsync(process.execPath, [
|
||||
"--import", `data:text/javascript,${encodeURIComponent(mockExec)}`,
|
||||
installer,
|
||||
...(process.platform === "linux" ? ["--allow-install-as-root"] : []),
|
||||
...args,
|
||||
], {
|
||||
cwd: directory,
|
||||
env: { ...process.env, CREATE_TEST_COMMANDS: path.join(directory, "commands.jsonl"), ...env },
|
||||
});
|
||||
}
|
||||
|
||||
// Commands run by the installer, without the getent lookups made when writing systemd units.
|
||||
async function commands() {
|
||||
const content = await fs.readFile(path.join(directory, "commands.jsonl"), "utf8");
|
||||
return content.split("\n").filter(Boolean).map(line => JSON.parse(line))
|
||||
.filter(command => !command.startsWith("getent "));
|
||||
}
|
||||
|
||||
function hostArgs(url = controllerUrl) {
|
||||
return [
|
||||
"--mode", "host", "--host-name", "Test Host", "--controller-url", url,
|
||||
"--public-address", "host.example.com", "--factorio-dir", "factorio", "--no-remote-npm", "--plugins",
|
||||
];
|
||||
}
|
||||
|
||||
it("writes the host config and leaves joining the cluster to the host", async function() {
|
||||
const { stdout } = await run(hostArgs());
|
||||
assert.match(stdout, /Successfully installed host/);
|
||||
|
||||
const recorded = await commands();
|
||||
assert.match(recorded[0], /^npm(?:\.cmd)? install @clusterio\/host$/);
|
||||
assert.deepEqual(recorded.slice(1), [
|
||||
`${hostBin} config create`,
|
||||
hostSet("host.name", "Test Host"),
|
||||
hostSet("host.controller_url", controllerUrl),
|
||||
hostSet("host.public_address", "host.example.com"),
|
||||
hostSet("host.factorio_directory", "factorio"),
|
||||
hostSet("host.allow_remote_updates", false),
|
||||
hostSet("host.allow_plugin_updates", false),
|
||||
hostSet("host.allow_plugin_install", false),
|
||||
]);
|
||||
await fs.access(path.join(directory, `run-host${process.platform === "win32" ? ".cmd" : ".sh"}`));
|
||||
});
|
||||
|
||||
it("adds the trailing slash the host requires to the controller URL", async function() {
|
||||
await run(hostArgs(controllerUrl.slice(0, -1)));
|
||||
assert((await commands()).includes(hostSet("host.controller_url", controllerUrl)));
|
||||
});
|
||||
|
||||
it("leaves joining the cluster to the host of a standalone install", async function() {
|
||||
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
|
||||
const { stdout } = await run([
|
||||
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
|
||||
"--factorio-dir", "factorio", "--remote-npm", "--plugins",
|
||||
]);
|
||||
assert.match(stdout, /Successfully installed standalone/);
|
||||
|
||||
const recorded = await commands();
|
||||
for (const expected of [
|
||||
hostSet("host.name", "local"),
|
||||
hostSet("host.controller_url", "http://localhost:8123/"),
|
||||
`${controllerBin} config set controller.allow_remote_updates true`,
|
||||
`${controllerBin} config set controller.allow_plugin_updates true`,
|
||||
`${controllerBin} config set controller.allow_plugin_install true`,
|
||||
hostSet("host.allow_remote_updates", true),
|
||||
hostSet("host.allow_plugin_updates", true),
|
||||
hostSet("host.allow_plugin_install", true),
|
||||
]) {
|
||||
assert(recorded.includes(expected), `missing ${expected}`);
|
||||
}
|
||||
assert(!recorded.some(command => command.includes("host.controller_token")));
|
||||
});
|
||||
|
||||
it("uses the host name given for a standalone install", async function() {
|
||||
await fs.writeFile(path.join(directory, "config-controller.json"), "{}");
|
||||
await run([
|
||||
"--mode", "standalone", "--admin", "admin", "--http-port", "8123", "--public-address", "localhost",
|
||||
"--factorio-dir", "factorio", "--remote-npm", "--plugins", "--host-name", "Test Host",
|
||||
]);
|
||||
assert((await commands()).includes(hostSet("host.name", "Test Host")));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -302,8 +302,7 @@ describe("host/src/Host", function() {
|
||||
await host.loadPlugins();
|
||||
const plugins = await host.handlePluginListRequest(new lib.PluginListRequest());
|
||||
const plugin = plugins.find(candidate => candidate.name === "controller_only");
|
||||
assert.equal(plugin.loaded, false);
|
||||
assert.equal(plugin.noEntrypoint, true);
|
||||
assert.equal(plugin.state, "noEntrypoint");
|
||||
const [entry] = computePluginStatuses([
|
||||
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
|
||||
]).get("controller_only").machines;
|
||||
|
||||
@@ -268,6 +268,46 @@ describe("class Instance", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".handleInstanceStopRequest()", function() {
|
||||
let calls;
|
||||
beforeEach(function() {
|
||||
calls = [];
|
||||
instance.server._state = "running";
|
||||
instance.server.stop = async () => { calls.push("stop"); instance.server._state = "stopping"; };
|
||||
instance.server.kill = async () => { calls.push("kill"); };
|
||||
instance.sendSaveListUpdate = async () => {};
|
||||
});
|
||||
|
||||
it("should stop once a start in progress finishes", async function() {
|
||||
instance.notifyStatus("starting");
|
||||
let stopped = instance.handleInstanceStopRequest();
|
||||
await wait(10);
|
||||
assert.deepEqual(calls, [], "stopped while starting");
|
||||
instance.notifyStatus("running");
|
||||
await stopped;
|
||||
assert.deepEqual(calls, ["stop"]);
|
||||
});
|
||||
|
||||
it("should do nothing if a start in progress fails", async function() {
|
||||
instance.notifyStatus("starting");
|
||||
let stopped = instance.handleInstanceStopRequest();
|
||||
await wait(10);
|
||||
instance.notifyStatus("stopped");
|
||||
await stopped;
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
it("should not kill the server when stopped while stopping", async function() {
|
||||
instance.notifyStatus("running");
|
||||
let first = instance.handleInstanceStopRequest();
|
||||
let second = instance.handleInstanceStopRequest();
|
||||
await first;
|
||||
instance.notifyStatus("stopped");
|
||||
await second;
|
||||
assert.deepEqual(calls, ["stop"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".checkModPackVersion()", function() {
|
||||
function modPack(factorioVersion) {
|
||||
return lib.ModPack.fromJSON({ name: "pack", factorio_version: factorioVersion });
|
||||
|
||||
+41
-12
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
|
||||
import { FactorioServer } from "@clusterio/host/dist/node/src/server.js";
|
||||
import {
|
||||
_exportLocale, _loadLayeredIcon, _loadSimpleIcon, _warnMissingBuiltinIcons,
|
||||
_createSheetState, _exportIcons, _exportLocale, _loadLayeredIcon, _loadSimpleIcon, _warnMissingBuiltinIcons,
|
||||
} from "@clusterio/host/dist/node/src/export.js";
|
||||
|
||||
|
||||
@@ -14,7 +14,25 @@ describe("host/src/export", function() {
|
||||
testServer = await FactorioServer.fromOptions(path.join("test", "file", "factorio"), writePath, {});
|
||||
});
|
||||
|
||||
let warnings;
|
||||
let originalLogger;
|
||||
beforeEach(function() {
|
||||
warnings = [];
|
||||
originalLogger = testServer._logger;
|
||||
testServer._logger = Object.create(originalLogger, { warn: { value: message => warnings.push(message) } });
|
||||
});
|
||||
afterEach(function() {
|
||||
testServer._logger = originalLogger;
|
||||
});
|
||||
|
||||
describe("exportLocale()", function() {
|
||||
it("warns about mods without a zip and exports the rest", async function() {
|
||||
const locale = await _exportLocale(
|
||||
testServer, new Map([["missing_mod", "1.0.0"]]), new Map(), ["base", "missing_mod"], "en"
|
||||
);
|
||||
assert.equal(locale.size > 0, true);
|
||||
assert.deepEqual(warnings, ["Skipping locale of missing_mod, its mod zip was not found"]);
|
||||
});
|
||||
it("returns a flattened mapping with locale definitions", async function() {
|
||||
let locale = await _exportLocale(testServer, new Map(), new Map(), ["base"], "en");
|
||||
assert.deepEqual(locale, new Map([
|
||||
@@ -32,6 +50,28 @@ describe("host/src/export", function() {
|
||||
|
||||
// test-icon.png is a red 64px icon followed by a blue 32px mipmap
|
||||
const red = 0xff0000ff;
|
||||
describe("exportIcons()", function() {
|
||||
it("skips icons that fail to load and exports the rest", async function() {
|
||||
const state = await _createSheetState();
|
||||
const icon = "__base__/graphics/icons/test-icon.png";
|
||||
const prototypes = [
|
||||
{ type: "item", name: "first", icon, icon_size: 64 },
|
||||
{ type: "item", name: "no-layers", icons: [] },
|
||||
{ type: "item", name: "unknown-mod", icon: "__not-a-mod__/graphics/icons/x.png", icon_size: 64 },
|
||||
{ type: "item", name: "last", icon, icon_size: 64 },
|
||||
];
|
||||
const count = await _exportIcons(testServer, new Map(), { zipCache: new Map() }, prototypes, "item", state);
|
||||
assert.equal(count, 2);
|
||||
assert.deepEqual(
|
||||
state.metadata.item.map(entry => [entry.name, Boolean(entry.icon)]),
|
||||
[["first", true], ["no-layers", false], ["unknown-mod", false], ["last", true]],
|
||||
);
|
||||
assert.equal(warnings.length, 2);
|
||||
assert.match(warnings[0], /^Skipping icon of item no-layers: /);
|
||||
assert.match(warnings[1], /^Skipping icon of item unknown-mod: Got path for unknown mod not-a-mod/);
|
||||
});
|
||||
});
|
||||
|
||||
// Stands in for the data dir of a full install
|
||||
const graphicsDataDir = path.join("test", "file", "factorio-graphics");
|
||||
describe("loadSimpleIcon()", function() {
|
||||
@@ -82,17 +122,6 @@ describe("host/src/export", function() {
|
||||
});
|
||||
});
|
||||
describe("warnMissingBuiltinIcons()", function() {
|
||||
let warnings;
|
||||
let logger;
|
||||
beforeEach(function() {
|
||||
warnings = [];
|
||||
logger = testServer._logger;
|
||||
testServer._logger = { warn: msg => warnings.push(msg) };
|
||||
});
|
||||
afterEach(function() {
|
||||
testServer._logger = logger;
|
||||
});
|
||||
|
||||
it("warns once with the number of missing builtin files", function() {
|
||||
_warnMissingBuiltinIcons(testServer, { zipCache: new Map() }, new Map([
|
||||
["__base__/graphics/icons/a.png", null],
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { PairingError, checkHostToken, pairHost } from "@clusterio/host";
|
||||
import { ensurePaired } from "@clusterio/host/dist/node/src/pairing.js";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
// Stub controller recording requests, responding with what the test's handler returns.
|
||||
async function startStub() {
|
||||
const stub = {
|
||||
requests: [],
|
||||
handler: () => [500, { error: "No handler" }],
|
||||
};
|
||||
stub.server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const entry = {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: chunks.length ? JSON.parse(Buffer.concat(chunks)) : undefined,
|
||||
};
|
||||
stub.requests.push(entry);
|
||||
const [status, json, headers = {}] = await stub.handler(entry);
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
||||
res.end(json === undefined ? undefined : JSON.stringify(json));
|
||||
});
|
||||
stub.server.listen(0, "127.0.0.1");
|
||||
await events.once(stub.server, "listening");
|
||||
stub.url = `http://127.0.0.1:${stub.server.address().port}/`;
|
||||
stub.close = async () => {
|
||||
stub.server.closeAllConnections();
|
||||
stub.server.close();
|
||||
await events.once(stub.server, "close");
|
||||
};
|
||||
return stub;
|
||||
}
|
||||
|
||||
const approved = { hostId: 1234, token: "host-token", name: "Approved" };
|
||||
|
||||
// Handler for requests that are approved after the given number of pending polls.
|
||||
function approveAfter(polls, result = approved) {
|
||||
let pending = 0;
|
||||
let requests = 0;
|
||||
return ({ url }) => {
|
||||
if (url === "/api/host-pairing") {
|
||||
requests += 1;
|
||||
return [202, { requestId: `secret-${requests}`, code: `ABC-DE${requests}` }];
|
||||
}
|
||||
if (url === "/api/host-pairing/poll") {
|
||||
pending += 1;
|
||||
return pending > polls ? [200, result] : [202, { status: "pending" }];
|
||||
}
|
||||
if (url === "/api/host-pairing/cancel") {
|
||||
return [204, undefined];
|
||||
}
|
||||
return [404, { error: "Not found" }];
|
||||
};
|
||||
}
|
||||
|
||||
describe("host/src/pairing", function() {
|
||||
let stub;
|
||||
beforeEach(async function() {
|
||||
stub = await startStub();
|
||||
});
|
||||
afterEach(async function() {
|
||||
await stub.close();
|
||||
});
|
||||
|
||||
function pair(options = {}) {
|
||||
return pairHost({ controllerUrl: stub.url, name: "Host", pollIntervalMs: 10, retryDelayMs: 10, ...options });
|
||||
}
|
||||
|
||||
describe("pairHost()", function() {
|
||||
it("returns the host when approved with a user token", async function() {
|
||||
stub.handler = ({ headers }) => (headers.authorization === "Bearer user-token"
|
||||
? [200, approved]
|
||||
: [401, { error: "Invalid user token" }]
|
||||
);
|
||||
const result = await pair({ token: "user-token", onPending: () => assert.fail("should not wait") });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
|
||||
it("polls until the request is approved", async function() {
|
||||
stub.handler = approveAfter(2);
|
||||
const codes = [];
|
||||
const result = await pair({ version: "2.1.0", onPending: code => { codes.push(code); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(codes, ["ABC-DE1"]);
|
||||
assert.deepEqual(stub.requests[0].body, { name: "Host", version: "2.1.0" });
|
||||
assert.equal(stub.requests[0].headers.authorization, undefined);
|
||||
for (const poll of stub.requests.slice(1)) {
|
||||
assert.deepEqual(poll.body, { requestId: "secret-1" });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves routes under the path of the controller URL", async function() {
|
||||
stub.handler = ({ url }) => (url === "/sub/api/host-pairing" ? [200, approved] : [404, {}]);
|
||||
const result = await pair({ controllerUrl: `${stub.url}sub/` });
|
||||
assert.equal(result.hostId, 1234);
|
||||
});
|
||||
|
||||
it("makes a new request when the previous one expired", async function() {
|
||||
const handler = approveAfter(0);
|
||||
let expired = false;
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll" && !expired) {
|
||||
expired = true;
|
||||
return [404, { error: "Request not found" }];
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
const codes = [];
|
||||
const result = await pair({ onPending: code => { codes.push(code); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(codes, ["ABC-DE1", "ABC-DE2"]);
|
||||
});
|
||||
|
||||
it("waits and tries again when the controller is busy", async function() {
|
||||
const handler = approveAfter(0);
|
||||
let busy = 2;
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing" && busy > 0) {
|
||||
busy -= 1;
|
||||
return busy ? [429, { error: "Too many requests" }] : [503, { error: "Recovery mode" }];
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
const retries = [];
|
||||
const result = await pair({ onRetry: err => { retries.push(err.message); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(retries, ["Too many requests", "Recovery mode"]);
|
||||
});
|
||||
|
||||
it("keeps trying while the controller can't be reached", async function() {
|
||||
const { url } = stub;
|
||||
await stub.close();
|
||||
const abort = new AbortController();
|
||||
const errors = [];
|
||||
await assert.rejects(
|
||||
pairHost({
|
||||
controllerUrl: url, name: "Host", retryDelayMs: 10, signal: abort.signal,
|
||||
onRetry: err => {
|
||||
errors.push(err);
|
||||
if (errors.length === 3) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: "AbortError" },
|
||||
);
|
||||
assert.equal(errors.length, 3);
|
||||
stub = await startStub();
|
||||
});
|
||||
|
||||
it("keeps polling while the controller is busy", async function() {
|
||||
const handler = approveAfter(0);
|
||||
let busy = 2;
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll" && busy > 0) {
|
||||
busy -= 1;
|
||||
return [503, { error: "Recovery mode" }];
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
const retries = [];
|
||||
const result = await pair({ onRetry: err => { retries.push(err.message); } });
|
||||
assert.deepEqual(result, approved);
|
||||
assert.deepEqual(retries, ["Recovery mode", "Recovery mode"]);
|
||||
});
|
||||
|
||||
it("throws when the request is rejected", async function() {
|
||||
const handler = approveAfter(Infinity);
|
||||
stub.handler = entry => (entry.url === "/api/host-pairing/poll" && stub.requests.length > 2
|
||||
? [403, { error: "Request was rejected" }]
|
||||
: handler(entry)
|
||||
);
|
||||
await assert.rejects(
|
||||
pair(),
|
||||
err => err instanceof PairingError && err.status === 403 && err.message === "Request was rejected",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if the controller does not support pairing", async function() {
|
||||
stub.handler = () => [404, undefined];
|
||||
await assert.rejects(
|
||||
pair(),
|
||||
err => err instanceof PairingError && err.status === 404 && /must be updated/.test(err.message),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an invalid user token without waiting", async function() {
|
||||
stub.handler = () => [401, { error: "Invalid user token" }];
|
||||
await assert.rejects(
|
||||
pair({ token: "bad" }),
|
||||
err => err instanceof PairingError && err.status === 401 && err.message === "Invalid user token",
|
||||
);
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
|
||||
it("cancels the request when aborted", async function() {
|
||||
const abort = new AbortController();
|
||||
const handler = approveAfter(Infinity);
|
||||
stub.handler = entry => {
|
||||
if (entry.url === "/api/host-pairing/poll") {
|
||||
abort.abort();
|
||||
}
|
||||
return handler(entry);
|
||||
};
|
||||
await assert.rejects(pair({ signal: abort.signal }), { name: "AbortError" });
|
||||
const cancel = stub.requests.find(r => r.url === "/api/host-pairing/cancel");
|
||||
assert.deepEqual(cancel?.body, { requestId: "secret-1" });
|
||||
});
|
||||
|
||||
it("does not send anything if already aborted", async function() {
|
||||
await assert.rejects(pair({ signal: AbortSignal.abort() }), { name: "AbortError" });
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkHostToken()", function() {
|
||||
it("returns true if the controller accepts the token", async function() {
|
||||
stub.handler = ({ url, headers }) => (
|
||||
url === "/api/host-pairing/check" && headers.authorization === "Bearer host-token"
|
||||
? [200, { valid: true }]
|
||||
: [401, undefined]
|
||||
);
|
||||
assert.equal(await checkHostToken(stub.url, "host-token"), true);
|
||||
assert.equal(await checkHostToken(stub.url, "other-token"), false);
|
||||
});
|
||||
|
||||
it("throws on other responses", async function() {
|
||||
stub.handler = () => [404, undefined];
|
||||
await assert.rejects(checkHostToken(stub.url, "host-token"), PairingError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePaired()", function() {
|
||||
let directory;
|
||||
let config;
|
||||
beforeEach(async function() {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-pairing-"));
|
||||
config = new lib.HostConfig("host", undefined, path.join(directory, "config-host.json"));
|
||||
config.set("host.name", "My Host");
|
||||
config.set("host.controller_url", stub.url);
|
||||
});
|
||||
afterEach(async function() {
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
async function savedConfig() {
|
||||
return JSON.parse(await fs.readFile(path.join(directory, "config-host.json"), "utf8"));
|
||||
}
|
||||
|
||||
it("waits for approval and saves the host when not paired", async function() {
|
||||
this.timeout(5000); // Polls at the default interval of 2 seconds
|
||||
stub.handler = approveAfter(0);
|
||||
const warnings = [];
|
||||
const originalWarn = lib.logger.warn;
|
||||
lib.logger.warn = message => { warnings.push(message); };
|
||||
try {
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
} finally {
|
||||
lib.logger.warn = originalWarn;
|
||||
}
|
||||
assert.deepEqual(stub.requests[0].body, { name: "My Host", version: "2.1.0" });
|
||||
// The systemd service created by the installer only prints warnings and above.
|
||||
assert(warnings.includes(`${stub.url}hosts?approve=ABC-DE1`), "approval link is not a warning");
|
||||
const saved = await savedConfig();
|
||||
assert.equal(saved["host.id"], 1234);
|
||||
assert.equal(saved["host.name"], "Approved");
|
||||
assert.equal(saved["host.controller_token"], "host-token");
|
||||
});
|
||||
|
||||
it("does nothing when the controller accepts the saved token", async function() {
|
||||
config.set("host.controller_token", "valid-token");
|
||||
stub.handler = ({ headers }) => (headers.authorization === "Bearer valid-token"
|
||||
? [200, { valid: true }]
|
||||
: [401, undefined]
|
||||
);
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
assert.deepEqual(stub.requests.map(r => r.url), ["/api/host-pairing/check"]);
|
||||
assert.equal(config.get("host.controller_token"), "valid-token");
|
||||
});
|
||||
|
||||
it("asks to join again with the user token when the saved token is not accepted", async function() {
|
||||
config.set("host.controller_token", "revoked-token");
|
||||
stub.handler = ({ url, headers }) => {
|
||||
if (url === "/api/host-pairing/check") {
|
||||
return [401, undefined];
|
||||
}
|
||||
return headers.authorization === "Bearer user-token"
|
||||
? [200, { hostId: 5, token: "new-token", name: "My Host" }]
|
||||
: [401, { error: "Invalid user token" }];
|
||||
};
|
||||
await ensurePaired(config, { version: "2.1.0", userToken: "user-token" });
|
||||
const saved = await savedConfig();
|
||||
assert.equal(saved["host.id"], 5);
|
||||
assert.equal(saved["host.controller_token"], "new-token");
|
||||
assert(!JSON.stringify(saved).includes("user-token"), "user token must not be saved");
|
||||
});
|
||||
|
||||
it("stops when aborted while checking the saved token", async function() {
|
||||
config.set("host.controller_token", "some-token");
|
||||
const abort = new AbortController();
|
||||
stub.handler = () => {
|
||||
abort.abort();
|
||||
return new Promise(() => {});
|
||||
};
|
||||
await assert.rejects(
|
||||
ensurePaired(config, { version: "2.1.0", signal: abort.signal }),
|
||||
{ name: "AbortError" },
|
||||
);
|
||||
assert.equal(config.get("host.controller_token"), "some-token");
|
||||
});
|
||||
|
||||
it("keeps the saved token when it can't be checked", async function() {
|
||||
config.set("host.controller_token", "some-token");
|
||||
stub.handler = () => [404, undefined];
|
||||
await ensurePaired(config, { version: "2.1.0" });
|
||||
assert.equal(config.get("host.controller_token"), "some-token");
|
||||
assert.equal(stub.requests.length, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+290
-68
@@ -10,11 +10,12 @@ import * as libBuildMod from "@clusterio/lib/build_mod.js";
|
||||
|
||||
import * as testStrings from "../lib/factorio/test_strings.js";
|
||||
import {
|
||||
TestControl, TestControlConnector, url, controlToken,
|
||||
TestControl, TestControlConnector, TestHostConnector, url, controlToken, hostId,
|
||||
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
|
||||
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
|
||||
requiresFactorio, hasFactorio,
|
||||
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
|
||||
} from "./index.js";
|
||||
import { pairHost, checkHostToken, PairingError } from "@clusterio/host";
|
||||
import { externalTest, slowTest } from "../common.js";
|
||||
import { getTestRconPort } from "./rcon_port.js";
|
||||
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
|
||||
@@ -31,6 +32,18 @@ async function checkInstanceStatus(id, status) {
|
||||
assert.equal(instances.get(44).status, status, "incorrect instance status");
|
||||
}
|
||||
|
||||
// Factorio directory relative to temp/test where the hosts are run from
|
||||
const hostFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
|
||||
/** Id, token and name of the alt-host, paired the first time it starts */
|
||||
const altHost = { hostId: undefined, token: undefined, name: undefined };
|
||||
|
||||
async function pairAltHost() {
|
||||
if (altHost.hostId === undefined) {
|
||||
Object.assign(altHost, await pairTestHost("alt-host"));
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAltHost(config = "alt-host-config.json") {
|
||||
return await spawnNode("alt-host:", `../../packages/host run --config ${config}`, /Started host/);
|
||||
}
|
||||
@@ -41,10 +54,10 @@ async function startAltHost() {
|
||||
await fs.rm(configPath, { force: true });
|
||||
await fs.rm(path.join("temp", "test", "alt-instances"), { force: true, recursive: true, maxRetries: 10 });
|
||||
await fs.rm(path.join("temp", "test", "alt-mods"), { force: true, recursive: true, maxRetries: 10 });
|
||||
await execCtl(`host create-config --id 5 --name alt-host --generate-token --output ${config}`);
|
||||
await pairAltHost();
|
||||
await writeHostConfig(configPath, altHost);
|
||||
await execHost(`--config ${config} config set host.instances_directory alt-instances`);
|
||||
const dir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
await execHost(`--config ${config} config set host.factorio_directory ${dir}`);
|
||||
await execHost(`--config ${config} config set host.factorio_directory ${hostFactorioDir}`);
|
||||
await execHost(`--config ${config} config set host.mods_directory alt-mods`);
|
||||
return await spawnAltHost(config);
|
||||
}
|
||||
@@ -57,7 +70,7 @@ async function stopAltHost(hostProcess) {
|
||||
"timeout"
|
||||
).catch(() => {});
|
||||
try {
|
||||
await execCtl("host stop 5");
|
||||
await execCtl(`host stop ${altHost.hostId}`);
|
||||
} catch (err) {
|
||||
// Some tests cause the host to stop
|
||||
}
|
||||
@@ -105,6 +118,28 @@ function jsonArg(value) {
|
||||
return `"${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}"`;
|
||||
}
|
||||
|
||||
/** Run a ctl command in this process and return what it printed */
|
||||
async function execCtlOutput(command) {
|
||||
const lines = [];
|
||||
const originalLog = console.log; // eslint-disable-line no-console
|
||||
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
||||
try {
|
||||
await execCtl(command);
|
||||
} finally {
|
||||
console.log = originalLog; // eslint-disable-line no-console
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Validator for assert.rejects expecting a PairingError with the given HTTP status */
|
||||
function pairingError(status) {
|
||||
return err => {
|
||||
assert(err instanceof PairingError, `Expected a PairingError, got ${err.stack}`);
|
||||
assert.equal(err.status, status, err.message);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
describe("Integration of Clusterio", function() {
|
||||
describe("clusteriocontroller", function() {
|
||||
describe("bootstrap generate-user-token", function() {
|
||||
@@ -281,11 +316,15 @@ describe("Integration of Clusterio", function() {
|
||||
describe("hostUpdateEventHandler()", function() {
|
||||
it("should trigger when a new host is added", async function() {
|
||||
slowTest(this);
|
||||
// Pairing sends an update for the new host before it connects, which is not what is tested here.
|
||||
// The round trip makes sure that update has been received before clearing the updates.
|
||||
await pairAltHost();
|
||||
await getControl().send(new lib.HostListRequest());
|
||||
getControl().hostUpdates = [];
|
||||
|
||||
await runWithAltHost(async () => {
|
||||
await execCtl("instance create alt-test --id 99");
|
||||
await execCtl("instance assign alt-test 5");
|
||||
await execCtl(`instance assign alt-test ${altHost.hostId}`);
|
||||
});
|
||||
|
||||
let sawUpdate = false;
|
||||
@@ -311,7 +350,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
let result = await getControl().send(new lib.HostListRequest());
|
||||
let hosts = new Map(result.map(instance => [instance.id, instance]));
|
||||
assert(hosts.has(5), "Host list was not updated");
|
||||
assert(hosts.has(altHost.hostId), "Host list was not updated");
|
||||
});
|
||||
});
|
||||
it("should download mods from controller", async function() {
|
||||
@@ -325,7 +364,7 @@ describe("Integration of Clusterio", function() {
|
||||
}
|
||||
assert(!await checkModDownloaded(), "mod was downloaded before the test");
|
||||
await execCtl("instance create alt-mod --id 98");
|
||||
await execCtl("instance assign alt-mod 5");
|
||||
await execCtl(`instance assign alt-mod ${altHost.hostId}`);
|
||||
await execCtl("instance save create alt-mod");
|
||||
assert(await checkModDownloaded(), "mod was not downloaded by the test");
|
||||
});
|
||||
@@ -340,7 +379,7 @@ describe("Integration of Clusterio", function() {
|
||||
hostProcess = await startAltHost();
|
||||
await execCtl("instance create alt-start --id 97");
|
||||
await execCtl(`instance config set alt-start factorio.rcon_port ${await getTestRconPort()}`);
|
||||
await execCtl("instance assign alt-start 5");
|
||||
await execCtl(`instance assign alt-start ${altHost.hostId}`);
|
||||
await execCtl("instance config set alt-start instance.auto_start true");
|
||||
const visibility = jsonArg({ lan: false, public: false });
|
||||
await execCtlProcess(`instance config set-prop alt-start factorio.settings visibility ${visibility}`);
|
||||
@@ -443,14 +482,12 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
it("should load the plugin on the controller", async function() {
|
||||
const details = await pluginDetails("controller");
|
||||
assert(details.enabled, "test_plugin is not enabled on the controller");
|
||||
assert(details.loaded, "test_plugin is not loaded on the controller");
|
||||
assert.equal(details.state, "loaded", "test_plugin is not loaded on the controller");
|
||||
});
|
||||
|
||||
it("should load the plugin on the host", async function() {
|
||||
const details = await pluginDetails({ hostId: 4 });
|
||||
assert(details.enabled, "test_plugin is not enabled on the host");
|
||||
assert(details.loaded, "test_plugin is not loaded on the host");
|
||||
const details = await pluginDetails({ hostId });
|
||||
assert.equal(details.state, "loaded", "test_plugin is not loaded on the host");
|
||||
});
|
||||
|
||||
it("should report the plugin's title and version", async function() {
|
||||
@@ -470,7 +507,7 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
|
||||
it("should route a plugin request from control to a host", async function() {
|
||||
const received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("never-broadcast"));
|
||||
const received = await getControl().sendTo({ hostId }, new HostEchoReceived("never-broadcast"));
|
||||
assert.equal(received, false);
|
||||
});
|
||||
|
||||
@@ -480,7 +517,7 @@ describe("Integration of Clusterio", function() {
|
||||
// The broadcast is sent without waiting for the hosts to receive it.
|
||||
let received = false;
|
||||
for (let attempt = 0; attempt < 100 && !received; attempt++) {
|
||||
received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("to-hosts"));
|
||||
received = await getControl().sendTo({ hostId }, new HostEchoReceived("to-hosts"));
|
||||
if (!received) {
|
||||
await lib.wait(20);
|
||||
}
|
||||
@@ -593,31 +630,31 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("host config", function() {
|
||||
it("changes host config", async function() {
|
||||
await execCtl("host config set 4 host.name My-Host");
|
||||
const result = await execCtlProcess("host config list 4");
|
||||
await execCtl(`host config set ${hostId} host.name My-Host`);
|
||||
const result = await execCtlProcess(`host config list ${hostId}`);
|
||||
assert(/My-Host/.test(result.stdout), "New host name not in config output");
|
||||
});
|
||||
it("should not allow setting host.controller_token", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("host config set 4 host.controller_token xyz"),
|
||||
execCtl(`host config set ${hostId} host.controller_token xyz`),
|
||||
/Field 'host\.controller_token' is not accessible/,
|
||||
);
|
||||
});
|
||||
it("should not allow setting host.id", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("host config set 4 host.id 200"),
|
||||
execCtl(`host config set ${hostId} host.id 200`),
|
||||
/Setting 'host\.id' while host is running is not supported/,
|
||||
);
|
||||
});
|
||||
it("should not leak host.controller_token", async function() {
|
||||
let result = await getControl().sendTo({ hostId: 4 }, new lib.HostConfigGetRequest());
|
||||
let result = await getControl().sendTo({ hostId }, new lib.HostConfigGetRequest());
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host plugin list", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host plugin list 4");
|
||||
await execCtl(`host plugin list ${hostId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -625,7 +662,7 @@ describe("Integration of Clusterio", function() {
|
||||
it("runs", async function() {
|
||||
// In dev plugins have no npm package, so best we can do is get an error from the host
|
||||
await assert.rejects(
|
||||
execCtl("host plugin update 4 foo"),
|
||||
execCtl(`host plugin update ${hostId} foo`),
|
||||
/Plugin foo is not installed on this machine/
|
||||
);
|
||||
});
|
||||
@@ -637,7 +674,7 @@ describe("Integration of Clusterio", function() {
|
||||
// Default is to disallow updates, changing this value would require a restart
|
||||
// Additionally, it can not be changed via ctl, so best we can do is get an error from the host
|
||||
await assert.rejects(
|
||||
execCtl("host plugin install 4 foo"),
|
||||
execCtl(`host plugin install ${hostId} foo`),
|
||||
/Plugin installs are disabled on this machine/
|
||||
);
|
||||
});
|
||||
@@ -646,15 +683,15 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("host update", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host update 4");
|
||||
await execCtl(`host update ${hostId}`);
|
||||
});
|
||||
it("accepts --all", async function() {
|
||||
await execCtl("host update 4 --all");
|
||||
await execCtl(`host update ${hostId} --all`);
|
||||
});
|
||||
it("accepts --restart", async function() {
|
||||
// We cannot restart the host, so we check for host error instead
|
||||
await assert.rejects(
|
||||
execCtl("host update 4 --restart"),
|
||||
execCtl(`host update ${hostId} --restart`),
|
||||
/Cannot restart, host does not have a process monitor to restart it./
|
||||
);
|
||||
});
|
||||
@@ -664,7 +701,7 @@ describe("Integration of Clusterio", function() {
|
||||
it("runs", async function() {
|
||||
// We cannot restart the host, so we check for host error instead
|
||||
await assert.rejects(
|
||||
execCtl("host restart 4"),
|
||||
execCtl(`host restart ${hostId}`),
|
||||
/Cannot restart, host does not have a process monitor to restart it./
|
||||
);
|
||||
});
|
||||
@@ -672,41 +709,226 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("host mark-restart-required", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host mark-restart-required 4");
|
||||
await execCtl(`host mark-restart-required ${hostId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host generate-token", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("host generate-token --id 42");
|
||||
describe("host pending, approve and reject", function() {
|
||||
let pairings = [];
|
||||
afterEach(async function() {
|
||||
// Stop hosts left waiting by a failed test
|
||||
for (const pairing of pairings) {
|
||||
pairing.abort.abort();
|
||||
}
|
||||
await Promise.allSettled(pairings.map(pairing => pairing.result));
|
||||
pairings = [];
|
||||
});
|
||||
it("runs without an id", async function() {
|
||||
await execCtl("host generate-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host revoke-token", async function() {
|
||||
it("should disconnect existing host", async function() {
|
||||
slowTest(this);
|
||||
let sawDisconnected;
|
||||
await runWithAltHost(async () => {
|
||||
const control = getControl();
|
||||
control.hostUpdates = [];
|
||||
await wait(50); // Allow for host to update instances
|
||||
await execCtl("host revoke-token 5");
|
||||
await wait(50); // Allow controller to notify control
|
||||
for (let update of control.hostUpdates) {
|
||||
if (update.name !== "alt-host") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!update.connected) {
|
||||
sawDisconnected = true;
|
||||
}
|
||||
}
|
||||
// Start pairing like a host without a user token, returns once the request is pending.
|
||||
async function requestPairing(name) {
|
||||
const abort = new AbortController();
|
||||
let onPending;
|
||||
const pending = new Promise(resolve => { onPending = resolve; });
|
||||
const result = pairHost({
|
||||
controllerUrl: url, name, pollIntervalMs: 100, signal: abort.signal, onPending,
|
||||
});
|
||||
const pairing = { abort, result, code: undefined };
|
||||
pairings.push(pairing);
|
||||
const ended = result.then(() => {
|
||||
throw new Error("Pairing finished before the request was pending");
|
||||
});
|
||||
ended.catch(() => {}); // Only of interest while racing the pending request
|
||||
pairing.code = await Promise.race([pending, ended]);
|
||||
return pairing;
|
||||
}
|
||||
|
||||
assert(sawDisconnected, "No host update with status disconnected was sent after revoking token");
|
||||
it("should list a waiting request and hand the host over when approved", async function() {
|
||||
const control = getControl();
|
||||
control.hostPairingUpdates = [];
|
||||
const pairing = await requestPairing("pending-host");
|
||||
assert.match(pairing.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
|
||||
|
||||
const pendingOutput = await execCtlOutput("host pending");
|
||||
const pendingLines = pendingOutput.split("\n");
|
||||
assert(
|
||||
pendingLines.some(line => line.includes(pairing.code) && line.includes("pending-host")),
|
||||
`Request missing from host pending output:\n${pendingOutput}`,
|
||||
);
|
||||
assert(
|
||||
control.hostPairingUpdates.some(update => update.code === pairing.code && !update.isDeleted),
|
||||
"No pairing update was sent for the new request",
|
||||
);
|
||||
|
||||
// Codes are matched ignoring case and separators
|
||||
const typedCode = pairing.code.replace("-", "").toLowerCase();
|
||||
const approveOutput = await execCtlOutput(`host approve ${typedCode} --name approved-host`);
|
||||
const approved = await pairing.result;
|
||||
assert.equal(approved.name, "approved-host");
|
||||
assert(approveOutput.includes(String(approved.hostId)), `Host id missing from: ${approveOutput}`);
|
||||
assert(
|
||||
control.hostPairingUpdates.some(update => update.code === pairing.code && update.isDeleted),
|
||||
"No pairing update was sent for the approved request",
|
||||
);
|
||||
|
||||
const requests = await control.send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.code === pairing.code), "Approved request is still pending");
|
||||
const hosts = await control.send(new lib.HostListRequest());
|
||||
assert.equal(hosts.find(host => host.id === approved.hostId)?.name, "approved-host");
|
||||
assert.equal(await checkHostToken(url, approved.token), true);
|
||||
|
||||
await execCtl(`host revoke ${approved.hostId}`);
|
||||
});
|
||||
|
||||
it("should make the host fail when the request is rejected", async function() {
|
||||
const pairing = await requestPairing("rejected-host");
|
||||
await execCtl(`host reject ${pairing.code}`);
|
||||
await assert.rejects(pairing.result, pairingError(403));
|
||||
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.code === pairing.code), "Rejected request is still pending");
|
||||
const hosts = await getControl().send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
|
||||
});
|
||||
|
||||
it("should remove the request when the host stops waiting", async function() {
|
||||
const pairing = await requestPairing("cancelled-host");
|
||||
pairing.abort.abort();
|
||||
await assert.rejects(pairing.result, err => err.name === "AbortError");
|
||||
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.code === pairing.code), "Cancelled request is still pending");
|
||||
});
|
||||
|
||||
it("should fail for a code that is not pending", async function() {
|
||||
await assert.rejects(execCtl("host approve 222-222"), /No pending host request with code 222-222/);
|
||||
await assert.rejects(execCtl("host reject 222-222"), /No pending host request with code 222-222/);
|
||||
});
|
||||
|
||||
it("should refuse an invalid user token instead of waiting for approval", async function() {
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: url, name: "invalid-token-host", token: "invalid" }), pairingError(401)
|
||||
);
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.name === "invalid-token-host"), "Request was created");
|
||||
});
|
||||
|
||||
it("should refuse a user token without permission to approve hosts", async function() {
|
||||
await getControl().send(new lib.UserCreateRequest("pairing-player"));
|
||||
try {
|
||||
const token = jwt.sign(
|
||||
{ aud: "user", user: "pairing-player" }, Buffer.from("TestSecretDoNotUse", "base64")
|
||||
);
|
||||
await assert.rejects(
|
||||
pairHost({ controllerUrl: url, name: "unapproved-host", token }), pairingError(403)
|
||||
);
|
||||
} finally {
|
||||
await getControl().send(new lib.UserDeleteRequest("pairing-player"));
|
||||
}
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
assert(!requests.some(request => request.name === "unapproved-host"), "Request was created");
|
||||
const hosts = await getControl().send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.name === "unapproved-host"), "Host was added");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host revoke", function() {
|
||||
it("should remove the host and invalidate its token", async function() {
|
||||
const pairing = await pairTestHost("revokee-host");
|
||||
assert.equal(await checkHostToken(url, pairing.token), true);
|
||||
await execCtl(`note set host ${pairing.hostId} short-lived`);
|
||||
|
||||
const control = getControl();
|
||||
control.hostUpdates = [];
|
||||
await execCtl(`host revoke ${pairing.hostId}`);
|
||||
assert(
|
||||
control.hostUpdates.some(update => update.id === pairing.hostId && update.isDeleted),
|
||||
"No host update with isDeleted was sent",
|
||||
);
|
||||
const hosts = await control.send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
||||
assert.equal(await checkHostToken(url, pairing.token), false);
|
||||
const notes = await control.send(new lib.NoteListRequest());
|
||||
assert(!notes.some(note => note.id === `host/${pairing.hostId}`), "Note of the host was not removed");
|
||||
});
|
||||
|
||||
it("should unassign the instances of the host", async function() {
|
||||
const pairing = await pairTestHost("assigned-host");
|
||||
// Instances can only be assigned to connected hosts, a link that accepts the assignment is enough.
|
||||
const connector = new TestHostConnector(url, 0.2);
|
||||
connector.token = pairing.token;
|
||||
connector.hostId = pairing.hostId;
|
||||
const link = new lib.Link(connector);
|
||||
link.handle(lib.InstanceAssignInternalRequest, async () => {});
|
||||
await connector.connect();
|
||||
await execCtl("instance create revoke-assigned --id 45");
|
||||
try {
|
||||
await execCtl(`instance assign revoke-assigned ${pairing.hostId}`);
|
||||
connector.setClosing(); // Stops it from reconnecting after being revoked
|
||||
await execCtl(`host revoke ${pairing.hostId}`);
|
||||
|
||||
const hosts = await getControl().send(new lib.HostListRequest());
|
||||
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
||||
const instance = await getControl().send(new lib.InstanceDetailsGetRequest(45));
|
||||
assert.equal(instance.assignedHost, undefined);
|
||||
assert.equal(instance.status, "unassigned");
|
||||
} finally {
|
||||
await execCtl("instance delete revoke-assigned");
|
||||
}
|
||||
});
|
||||
|
||||
it("should stop a running host and have it ask to join again when started", async function() {
|
||||
slowTest(this);
|
||||
this.timeout(30000);
|
||||
const config = "revoke-host-config.json";
|
||||
const pairing = await pairTestHost("running-revokee");
|
||||
await writeHostConfig(path.join("temp", "test", config), pairing, {
|
||||
"host.factorio_directory": hostFactorioDir,
|
||||
"host.instances_directory": "revoke-instances",
|
||||
"host.mods_directory": "revoke-mods",
|
||||
});
|
||||
const hostProcess = await spawnNode(
|
||||
"revoke-host:", `../../packages/host run --config ${config}`, /Started host/
|
||||
);
|
||||
try {
|
||||
const exited = events.once(hostProcess, "exit");
|
||||
await execCtl(`host revoke ${pairing.hostId}`);
|
||||
assert.notEqual(
|
||||
await lib.timeout(exited, 10e3, "timeout"), "timeout", "Host did not stop after being revoked"
|
||||
);
|
||||
} finally {
|
||||
if (hostProcess.exitCode === null && hostProcess.signalCode === null) {
|
||||
hostProcess.kill("SIGINT");
|
||||
}
|
||||
}
|
||||
|
||||
// Starting it again asks to join the cluster again, and it starts up once approved
|
||||
const rejoining = await spawnNode(
|
||||
"revoke-host:", `../../packages/host run --config ${config}`, /Waiting for approval/
|
||||
);
|
||||
const exited = events.once(rejoining, "exit");
|
||||
try {
|
||||
const started = new Promise(resolve => {
|
||||
rejoining.stdout.on("data", chunk => {
|
||||
if (String(chunk).includes("Started host")) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
const requests = await getControl().send(new lib.HostPairingListRequest());
|
||||
const request = requests.find(entry => entry.name === "running-revokee");
|
||||
assert(request, "Revoked host did not ask to join again");
|
||||
const newHostId = await getControl().send(new lib.HostPairingApproveRequest(request.code));
|
||||
assert.notEqual(newHostId, pairing.hostId);
|
||||
assert.notEqual(
|
||||
await lib.timeout(started, 10e3, "timeout"), "timeout", "Host did not start once approved"
|
||||
);
|
||||
await execCtl(`host revoke ${newHostId}`);
|
||||
await lib.timeout(exited, 10e3, "timeout");
|
||||
} finally {
|
||||
if (rejoining.exitCode === null && rejoining.signalCode === null) {
|
||||
rejoining.kill("SIGINT");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -751,7 +973,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("instance assign", function() {
|
||||
it("creates the instance files", async function() {
|
||||
await execCtl("instance assign test 4");
|
||||
await execCtl(`instance assign test ${hostId}`);
|
||||
await assert.doesNotReject(
|
||||
fs.access(path.join(instancesDir, "test")),
|
||||
"Instance directory was not created"
|
||||
@@ -1046,14 +1268,14 @@ describe("Integration of Clusterio", function() {
|
||||
assert.equal(controllerEntry.host_id, undefined);
|
||||
assert.equal(controllerEntry.instance_id, undefined);
|
||||
|
||||
const hostEntry = await findLog({ hostIds: [4] }, "test_plugin host echo log-tagging");
|
||||
const hostEntry = await findLog({ hostIds: [hostId] }, "test_plugin host echo log-tagging");
|
||||
assert.equal(hostEntry.plugin, "test_plugin");
|
||||
assert.equal(hostEntry.host_id, 4);
|
||||
assert.equal(hostEntry.host_id, hostId);
|
||||
assert.equal(hostEntry.instance_id, undefined);
|
||||
|
||||
const instanceEntry = await findLog({ instanceIds: [44] }, "test_plugin instance echo log-tagging");
|
||||
assert.equal(instanceEntry.plugin, "test_plugin");
|
||||
assert.equal(instanceEntry.host_id, 4);
|
||||
assert.equal(instanceEntry.host_id, hostId);
|
||||
assert.equal(instanceEntry.instance_id, 44);
|
||||
});
|
||||
});
|
||||
@@ -1523,7 +1745,7 @@ describe("Integration of Clusterio", function() {
|
||||
before(async function() {
|
||||
slowTest(this);
|
||||
await execCtlProcess("instance create spam --id 66");
|
||||
await execCtlProcess("instance assign spam 4");
|
||||
await execCtlProcess(`instance assign spam ${hostId}`);
|
||||
await execCtlProcess("instance create unassign --id 77");
|
||||
});
|
||||
after(async function() {
|
||||
@@ -1550,7 +1772,7 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
hostProcess = await startAltHost();
|
||||
await execCtlProcess("instance create save-test --id 88");
|
||||
await execCtlProcess("instance assign save-test 5");
|
||||
await execCtlProcess(`instance assign save-test ${altHost.hostId}`);
|
||||
});
|
||||
after(async function() {
|
||||
await stopAltHost(hostProcess);
|
||||
@@ -1978,9 +2200,9 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("note set", function() {
|
||||
it("should set the note of the given resource", async function() {
|
||||
await execCtl("note set host 4 basement-box");
|
||||
await execCtl(`note set host ${hostId} basement-box`);
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
const note = notes.find(n => n.id === "host/4");
|
||||
const note = notes.find(n => n.id === `host/${hostId}`);
|
||||
assert.equal(note.content, "basement-box");
|
||||
assert.equal(note.updatedBy, "test");
|
||||
});
|
||||
@@ -2001,7 +2223,7 @@ describe("Integration of Clusterio", function() {
|
||||
const originalLog = console.log; // eslint-disable-line no-console
|
||||
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
||||
try {
|
||||
await execCtl("note get host 4");
|
||||
await execCtl(`note get host ${hostId}`);
|
||||
} finally {
|
||||
console.log = originalLog; // eslint-disable-line no-console
|
||||
}
|
||||
@@ -2017,9 +2239,9 @@ describe("Integration of Clusterio", function() {
|
||||
|
||||
describe("note delete", function() {
|
||||
it("should remove the note of the given resource", async function() {
|
||||
await execCtl("note delete host 4");
|
||||
await execCtl(`note delete host ${hostId}`);
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
assert(!notes.find(n => n.id === "host/4"), "Note was not deleted");
|
||||
assert(!notes.find(n => n.id === `host/${hostId}`), "Note was not deleted");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+52
-12
@@ -12,6 +12,7 @@ import { LineSplitter, logger } from "@clusterio/lib";
|
||||
|
||||
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
|
||||
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
|
||||
import { pairHost } from "@clusterio/host";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
@@ -26,6 +27,7 @@ export class TestControl extends lib.Link {
|
||||
constructor(connector, subscribe = true) {
|
||||
super(connector);
|
||||
this.hostUpdates = [];
|
||||
this.hostPairingUpdates = [];
|
||||
this.instanceUpdates = [];
|
||||
this.saveUpdates = [];
|
||||
this.modUpdates = [];
|
||||
@@ -39,6 +41,9 @@ export class TestControl extends lib.Link {
|
||||
this.send(
|
||||
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
|
||||
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
|
||||
this.send(
|
||||
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true)
|
||||
).catch(err => logger.error(`Error setting host pairing subscriptions:\n${err.stack}`));
|
||||
this.send(
|
||||
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
|
||||
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
|
||||
@@ -58,6 +63,7 @@ export class TestControl extends lib.Link {
|
||||
|
||||
this.handle(lib.AccountUpdateEvent);
|
||||
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
|
||||
this.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.bind(this));
|
||||
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
|
||||
@@ -69,6 +75,10 @@ export class TestControl extends lib.Link {
|
||||
this.hostUpdates.push(...event.updates);
|
||||
}
|
||||
|
||||
async handleHostPairingUpdatesEvent(event) {
|
||||
this.hostPairingUpdates.push(...event.updates);
|
||||
}
|
||||
|
||||
async handleInstanceDetailsUpdatesEvent(event) {
|
||||
this.instanceUpdates.push(...event.updates);
|
||||
}
|
||||
@@ -181,6 +191,8 @@ const baseHostConfig = loadJSON("config-host.json");
|
||||
export let url = "https://localhost:4443/";
|
||||
// Signed in the root before hook, as the user has to exist before the token is issued
|
||||
export let controlToken;
|
||||
// Id of the test host, assigned by the controller when it is paired in the root before hook
|
||||
export let hostId;
|
||||
export let instancesDir = path.join("temp", "test", "instances");
|
||||
export let modsDir = path.join("temp", "test", "mods");
|
||||
export let databaseDir = path.join("temp", "test", "database");
|
||||
@@ -205,17 +217,17 @@ export function exec(command, options = {}) {
|
||||
}
|
||||
|
||||
export function execController(...args) {
|
||||
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
|
||||
args[0] = `node --enable-source-maps ../../packages/controller/dist/node/controller.js ${args[0]}`;
|
||||
return exec(...args);
|
||||
}
|
||||
|
||||
export function execHost(...args) {
|
||||
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
|
||||
args[0] = `node --enable-source-maps ../../packages/host/dist/node/host.js ${args[0]}`;
|
||||
return exec(...args);
|
||||
}
|
||||
|
||||
export function execCtlProcess(...args) {
|
||||
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
|
||||
args[0] = `node --enable-source-maps ../../packages/ctl/dist/node/ctl.js ${args[0]}`;
|
||||
return exec(...args);
|
||||
}
|
||||
|
||||
@@ -253,6 +265,35 @@ export function getControl() {
|
||||
return control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair a new host with the test controller, approved right away with the admin token
|
||||
*
|
||||
* @param {string} name - Name to give the host.
|
||||
* @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host.
|
||||
*/
|
||||
export async function pairTestHost(name) {
|
||||
return await pairHost({ controllerUrl: url, name, token: controlToken });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new host config for a paired host, with the fields the installer and pairing set
|
||||
*
|
||||
* @param {string} configPath - Path to write the config to.
|
||||
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
|
||||
* @param {Record<string, unknown>} fields - Other fields to set in the config.
|
||||
*/
|
||||
export async function writeHostConfig(configPath, pairing, fields = {}) {
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
hostConfig.set("host.id", pairing.hostId);
|
||||
hostConfig.set("host.name", pairing.name);
|
||||
hostConfig.set("host.controller_url", url);
|
||||
hostConfig.set("host.controller_token", pairing.token);
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
hostConfig.set(name, value);
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** @returns {Promise<child_process.ChildProcess>} */
|
||||
export function spawn(name, cmd, waitFor, options = {}) {
|
||||
|
||||
@@ -270,22 +311,19 @@ export function spawn(name, cmd, waitFor, options = {}) {
|
||||
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||
let onDataOut = line => {
|
||||
let onData = line => {
|
||||
line = line.toString("utf8");
|
||||
if (waitFor.test(line)) {
|
||||
if (silent) {
|
||||
stdout.off("data", onDataOut);
|
||||
stderr.off("data", onDataErr);
|
||||
stdout.off("data", onData);
|
||||
stderr.off("data", onData);
|
||||
}
|
||||
resolve(process);
|
||||
}
|
||||
log(name, line);
|
||||
};
|
||||
let onDataErr = line => {
|
||||
log(name, line.toString("utf8"));
|
||||
};
|
||||
stdout.on("data", onDataOut);
|
||||
stderr.on("data", onDataErr);
|
||||
stdout.on("data", onData);
|
||||
stderr.on("data", onData);
|
||||
process.stdout.pipe(stdout);
|
||||
process.stderr.pipe(stderr);
|
||||
});
|
||||
@@ -366,7 +404,9 @@ before(async function() {
|
||||
haveFactorioInstall = factorioVersions.size > 0;
|
||||
|
||||
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
||||
await execCtlProcess("host create-config --id 4 --name host --generate-token");
|
||||
const pairing = await pairTestHost("host");
|
||||
hostId = pairing.hostId;
|
||||
await writeHostConfig(hostConfigPath, pairing);
|
||||
await execHost("config create");
|
||||
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import fs from "node:fs/promises";
|
||||
import { slowTest, testMatrix } from "../common.js";
|
||||
import {
|
||||
execCtl, execCtlProcess, sendRcon, getControl,
|
||||
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir,
|
||||
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir, hostId,
|
||||
} from "./index.js";
|
||||
|
||||
import { getTestRconPort } from "./rcon_port.js";
|
||||
@@ -96,7 +96,7 @@ describe("Clusterio Instance", function() {
|
||||
await execCtl(`instance config set ${instName} factorio.enable_whitelist true`);
|
||||
await execCtlProcess(`instance config set-prop ${instName} factorio.settings visibility "${visibility}"`);
|
||||
await execCtl(`instance config set-prop ${instName} factorio.settings require_user_verification false`);
|
||||
await execCtl(`instance assign ${instName} 4`);
|
||||
await execCtl(`instance assign ${instName} ${hostId}`);
|
||||
// Exclude main test instance from start-all to prevent interference
|
||||
await execCtl(`instance config set ${instName} instance.exclude_from_start_all true`);
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("Clusterio Instance", function() {
|
||||
await execCtl(`instance config set ${instAltName} factorio.enable_whitelist true`);
|
||||
await execCtlProcess(`instance config set-prop ${instAltName} factorio.settings visibility "${visibility}"`);
|
||||
await execCtl(`instance config set-prop ${instAltName} factorio.settings require_user_verification false`);
|
||||
await execCtl(`instance assign ${instAltName} 4`);
|
||||
await execCtl(`instance assign ${instAltName} ${hostId}`);
|
||||
// Exclude alt test instance from start-all to prevent interference
|
||||
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
|
||||
await execCtl(`instance start ${instAltName}`);
|
||||
@@ -340,9 +340,9 @@ describe("start-all and stop-all commands", function() {
|
||||
await execCtl(`instance create ${testInstName3} --id ${testInstId3}`);
|
||||
|
||||
// Configure instances
|
||||
await execCtl(`instance assign ${testInstName1} 4`);
|
||||
await execCtl(`instance assign ${testInstName2} 4`);
|
||||
await execCtl(`instance assign ${testInstName3} 4`);
|
||||
await execCtl(`instance assign ${testInstName1} ${hostId}`);
|
||||
await execCtl(`instance assign ${testInstName2} ${hostId}`);
|
||||
await execCtl(`instance assign ${testInstName3} ${hostId}`);
|
||||
const visibility = JSON.stringify({ lan: false, public: false }).replace(
|
||||
/"/g, process.platform === "win32" ? '""' : '\\"'
|
||||
);
|
||||
@@ -474,4 +474,26 @@ describe("start-all and stop-all commands", function() {
|
||||
assert.equal(await getInstanceStatus(testInstId3), "stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop while starting", function() {
|
||||
it("stops the instance once it has started", async function() {
|
||||
this.timeout(30000);
|
||||
const control = getControl();
|
||||
const started = control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStartRequest());
|
||||
// Unroutable until the host has connected the instance
|
||||
for (;;) {
|
||||
try {
|
||||
await control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStopRequest());
|
||||
break;
|
||||
} catch (err) {
|
||||
if (!(err instanceof lib.UnavailableError)) {
|
||||
throw err;
|
||||
}
|
||||
await lib.wait(10);
|
||||
}
|
||||
}
|
||||
await started;
|
||||
assert.equal(await getInstanceStatus(testInstId1), "stopped");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,25 +3,36 @@ import events from "node:events";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import {
|
||||
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl,
|
||||
TestControlConnector, TestHostConnector, TestControl, url, controlToken, getControl, hostId, pairTestHost,
|
||||
} from "./index.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib";
|
||||
import { slowTest } from "../common.js";
|
||||
|
||||
let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
// Valid token for a host id that was never approved
|
||||
let tokenUnregistered = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
|
||||
describe("Integration of lib/link", function() {
|
||||
let control;
|
||||
let controlConnector;
|
||||
let hostConnector;
|
||||
// Hosts can only connect once approved, so the host connections use a paired host
|
||||
let linkHost;
|
||||
before(async function() {
|
||||
linkHost = await pairTestHost("link-test");
|
||||
});
|
||||
after(async function() {
|
||||
if (linkHost) {
|
||||
await getControl().send(new lib.HostRevokeRequest(linkHost.hostId));
|
||||
}
|
||||
});
|
||||
beforeEach(async function() {
|
||||
controlConnector = new TestControlConnector(url, 0.2);
|
||||
controlConnector.token = controlToken;
|
||||
control = new TestControl(controlConnector, false);
|
||||
hostConnector = new TestHostConnector(url, 0.2);
|
||||
hostConnector.token = tokenHost;
|
||||
hostConnector.hostId = 0;
|
||||
hostConnector.token = linkHost.token;
|
||||
hostConnector.hostId = linkHost.hostId;
|
||||
});
|
||||
afterEach(async function() {
|
||||
await controlConnector.disconnect();
|
||||
@@ -138,16 +149,29 @@ describe("Integration of lib/link", function() {
|
||||
});
|
||||
|
||||
it("should refuse connection with mismatched ids", async function() {
|
||||
hostConnector.hostId = 1;
|
||||
hostConnector.hostId = linkHost.hostId + 1;
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error("Authentication failed: missmatched host id")
|
||||
);
|
||||
});
|
||||
|
||||
it("should refuse connection for a host that is not registered", async function() {
|
||||
hostConnector.token = tokenUnregistered;
|
||||
hostConnector.hostId = 0;
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error(
|
||||
"Authentication failed: host is not registered, start the host again to request to join the cluster"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("should close old connections from the same address", async function() {
|
||||
slowTest(this);
|
||||
const hostConnectorFirst = new TestHostConnector(url, 0.2);
|
||||
// Without a link the disconnect prepare goes unanswered and the controller waits 10 s for it
|
||||
const hostLinkFirst = new lib.Link(new TestHostConnector(url, 0.2));
|
||||
const hostConnectorFirst = hostLinkFirst.connector;
|
||||
hostConnectorFirst.token = hostConnector.token;
|
||||
hostConnectorFirst.hostId = hostConnector.hostId;
|
||||
const onceClose = events.once(hostConnectorFirst, "close");
|
||||
@@ -163,8 +187,8 @@ describe("Integration of lib/link", function() {
|
||||
it("should refuse duplicate host connections", async function() {
|
||||
this.skip(); // Can't be checked because the remote address for both connections is the same
|
||||
|
||||
hostConnector.token = jwt.sign({ aud: "host", host: 4 }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
hostConnector.hostId = 4; // Same as already running host
|
||||
hostConnector.token = jwt.sign({ aud: "host", host: hostId }, Buffer.from("TestSecretDoNotUse", "base64"));
|
||||
hostConnector.hostId = hostId; // Same as already running host
|
||||
await assert.rejects(
|
||||
hostConnector.connect(),
|
||||
new Error("Authentication failed: missmatched host id")
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Host, Instance, InstanceConnection } from "@clusterio/host";
|
||||
import { ControlConnection, Controller, HostConnection, InstanceRecord } from "@clusterio/controller";
|
||||
import { ControlConnection, Controller, HostConnection, HostRecord, InstanceRecord } from "@clusterio/controller";
|
||||
import { MockServer } from "../mock.js";
|
||||
|
||||
// Although there are no imports, the file has side-effects such as "before" which can effect tests
|
||||
@@ -19,6 +19,8 @@ class Control extends lib.Link { }
|
||||
function connectHost(controller, hostId, plugins) {
|
||||
const [controllerSide, hostSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ hostId }));
|
||||
const registerData = new lib.RegisterHostData("", "0.0.0", hostId, plugins);
|
||||
// Hosts can only connect once approved, which creates their record.
|
||||
controller.hosts.set(new HostRecord(hostId, `host-${hostId}`, "", new Map()));
|
||||
const hostConnection = new HostConnection(registerData, controllerSide, controller, "host-a.test");
|
||||
controller.wsServer.hostConnections.set(hostId, hostConnection);
|
||||
const hostConfig = new lib.HostConfig("host");
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HostPairingDetails } from "@clusterio/lib";
|
||||
|
||||
import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js";
|
||||
|
||||
describe("lib/data/HostPairingDetails", function () {
|
||||
|
||||
it("should be round trip serialisable", function () {
|
||||
testRoundTripJsonSerialisable(HostPairingDetails, testMatrix(
|
||||
["ABC-DEF"], // code (required)
|
||||
["New Host", ""], // name (required)
|
||||
["10.0.0.1", "2001:db8::1"], // remoteAddress (required)
|
||||
[undefined, "2.0.0"], // version
|
||||
[undefined, 123456], // createdAtMs
|
||||
[undefined, 999999], // updatedAtMs
|
||||
[undefined, true], // isDeleted
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("lib/data/SystemInfo", function() {
|
||||
testRoundTripJsonSerialisable(SystemInfo, [
|
||||
base,
|
||||
[...base, []],
|
||||
[...base, [new PluginDetails("name", "Title", "1.0.0", true, true, undefined, "name", "1.1.0")]],
|
||||
[...base, [new PluginDetails("name", "Title", "1.0.0", "loaded", undefined, "name", "1.1.0")]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,6 +346,41 @@ describe("lib/datastore", function() {
|
||||
await datastore.save();
|
||||
assert.equal(datastoreProvider.methodCalls.length, 2);
|
||||
});
|
||||
it("should write overlapping saves in order with the latest data", async function() {
|
||||
const written = [];
|
||||
let release;
|
||||
datastoreProvider.save = async data => {
|
||||
const keys = [...data.keys()];
|
||||
if (!written.length) {
|
||||
await new Promise(resolve => { release = resolve; });
|
||||
}
|
||||
written.push(keys);
|
||||
};
|
||||
datastore = new MockDatastore(datastoreProvider, new Map([["a", 1]]));
|
||||
datastore.callTouch();
|
||||
const first = datastore.save();
|
||||
await new Promise(resolve => setImmediate(resolve)); // First write in progress
|
||||
datastore.getData().set("b", 2);
|
||||
datastore.callTouch();
|
||||
const second = datastore.save();
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(written, [["a"], ["a", "b"]]);
|
||||
});
|
||||
it("should save again after a failed save", async function() {
|
||||
let fail = true;
|
||||
datastoreProvider.save = async () => {
|
||||
if (fail) {
|
||||
fail = false;
|
||||
throw new Error("disk full");
|
||||
}
|
||||
datastoreProvider.methodCalls.push(["save"]);
|
||||
};
|
||||
datastore.callTouch();
|
||||
await assert.rejects(datastore.save(), /disk full/);
|
||||
await datastore.save();
|
||||
assert.deepEqual(datastoreProvider.methodCalls.at(-1), ["save"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load", function() {
|
||||
|
||||
+14
-12
@@ -414,12 +414,12 @@ describe("lib/plugin_loader", function() {
|
||||
const loaded = byPackage.get("plugin-npm");
|
||||
assert.equal(loaded.version, "0.0.0");
|
||||
assert.equal(loaded.installedVersion, "0.0.1");
|
||||
assert.equal(loaded.pendingInstall, undefined);
|
||||
assert.equal(loaded.state, "loaded");
|
||||
|
||||
// Dependencies of plugin packages are found the same way as on startup
|
||||
for (const packageName of ["plugin-aliased", "plugin-deep", "plugin-transitive"]) {
|
||||
const pending = byPackage.get(packageName);
|
||||
assert.equal(pending.pendingInstall, true, packageName);
|
||||
assert.equal(pending.state, "pendingInstall", packageName);
|
||||
assert.equal(pending.installedVersion, "0.0.1", packageName);
|
||||
assert.equal(pending.loaded, false, packageName);
|
||||
}
|
||||
@@ -513,13 +513,12 @@ describe("lib/plugin_loader", function() {
|
||||
};
|
||||
let plugins = await lib.listPluginDetails([local], () => true, () => true);
|
||||
const taken = plugins.find(plugin => plugin.name === "plugin-npm");
|
||||
assert.equal(taken.nameTaken, true);
|
||||
assert.equal(taken.pendingInstall, undefined);
|
||||
assert.equal(taken.state, "nameTaken");
|
||||
|
||||
// Loaded on the next restart once the plugin with that name is gone
|
||||
const gone = { ...local, packagePath: path.resolve("plugins", "gone", "package.json") };
|
||||
plugins = await lib.listPluginDetails([gone], () => true, () => true);
|
||||
assert.equal(plugins.find(plugin => plugin.name === "plugin-npm").pendingInstall, true);
|
||||
assert.equal(plugins.find(plugin => plugin.name === "plugin-npm").state, "pendingInstall");
|
||||
});
|
||||
|
||||
it("should list a dependency made direct since startup as pending", async function() {
|
||||
@@ -531,7 +530,7 @@ describe("lib/plugin_loader", function() {
|
||||
await fs.writeFile("package.json", JSON.stringify(root));
|
||||
const plugins = await lib.listPluginDetails([], () => true, () => true);
|
||||
const hidden = plugins.find(plugin => plugin.name === "plugin-hidden");
|
||||
assert.equal(hidden.pendingInstall, true);
|
||||
assert.equal(hidden.state, "pendingInstall");
|
||||
assert.equal(hidden.npmPackage, "plugin-hidden");
|
||||
} finally {
|
||||
await fs.writeFile("package.json", rootPackageJson);
|
||||
@@ -573,7 +572,7 @@ describe("lib/plugin_loader", function() {
|
||||
assert.equal(npm.npmPackage, undefined);
|
||||
assert.equal(npm.dependencyName, "plugin-npm");
|
||||
const deep = plugins.find(plugin => plugin.name === "plugin-deep");
|
||||
assert.equal(deep.pendingInstall, true);
|
||||
assert.equal(deep.state, "pendingInstall");
|
||||
assert.equal(deep.npmPackage, undefined);
|
||||
} finally {
|
||||
await fs.writeFile("package.json", rootPackageJson);
|
||||
@@ -637,7 +636,7 @@ describe("lib/plugin_loader", function() {
|
||||
npmPackage: "plugin-npm",
|
||||
}], () => true, () => true);
|
||||
assert.equal(plugin.npmPackage, undefined);
|
||||
assert.equal(plugin.removed, undefined);
|
||||
assert.equal(plugin.state, "loaded");
|
||||
} finally {
|
||||
await fs.writeFile(packageJsonPath, original);
|
||||
}
|
||||
@@ -650,7 +649,7 @@ describe("lib/plugin_loader", function() {
|
||||
await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), private: true }));
|
||||
const plugins = await lib.listPluginDetails([], () => true, () => true);
|
||||
const deep = plugins.find(plugin => plugin.name === "plugin-deep");
|
||||
assert.equal(deep.pendingInstall, true);
|
||||
assert.equal(deep.state, "pendingInstall");
|
||||
assert.equal(deep.npmPackage, undefined);
|
||||
} finally {
|
||||
await fs.writeFile(packageJsonPath, original);
|
||||
@@ -705,7 +704,7 @@ describe("lib/plugin_loader", function() {
|
||||
assert.equal(alias.npmPackage, undefined);
|
||||
|
||||
const published = new lib.PluginDetails(
|
||||
"published", "Published", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
|
||||
"published", "Published", "1.0.0", "loaded", undefined, "plugin-aliased", "1.0.0",
|
||||
);
|
||||
const statuses = computePluginStatuses([
|
||||
{ name: "A", isController: false, address: { hostId: 1 }, plugins: [published] },
|
||||
@@ -740,9 +739,12 @@ describe("lib/plugin_loader", function() {
|
||||
}];
|
||||
const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true);
|
||||
const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin]));
|
||||
assert.equal(byPackage.get("plugin-gone").removed, true);
|
||||
assert.equal(byPackage.get("plugin-gone").state, "removedLoaded");
|
||||
assert.equal(byPackage.get("plugin-gone").installedVersion, undefined);
|
||||
assert.equal(byPackage.get("plugin-npm").removed, undefined);
|
||||
assert.equal(byPackage.get("plugin-npm").state, "loaded");
|
||||
|
||||
const [disabled] = await lib.listPluginDetails([pluginInfos[0]], () => false, () => false);
|
||||
assert.equal(disabled.state, "removed");
|
||||
});
|
||||
|
||||
it("should load existing plugin list", async function() {
|
||||
|
||||
@@ -1250,6 +1250,36 @@ describe("lib/subscriptions", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("permitted", function() {
|
||||
let permitted;
|
||||
let subscriber;
|
||||
|
||||
beforeEach(function() {
|
||||
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
|
||||
permitted = false;
|
||||
subscriber = new lib.EventSubscriber(RegisteredEvent, mockControl, undefined, () => permitted);
|
||||
});
|
||||
|
||||
it("should send nothing while not permitted", async function() {
|
||||
subscriber.subscribe(() => {});
|
||||
mockControl.connector.emit("connect");
|
||||
subscriber.handleConnectionEvent("resume");
|
||||
subscriber.handleAccountEvent();
|
||||
await new Promise(r => setImmediate(r));
|
||||
assertNoMessageSent();
|
||||
});
|
||||
|
||||
it("should subscribe when the account becomes permitted", async function() {
|
||||
subscriber.subscribe(() => {});
|
||||
permitted = true;
|
||||
subscriber.handleAccountEvent();
|
||||
await onceConnectorSend();
|
||||
assertLastMessage(new lib.SubscriptionRequest(
|
||||
RegisteredEvent.name, "replace", -1, lib.SubscriptionFilters.all()
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe(".handleAccountEvent()", function() {
|
||||
it("should send replace(all) if subscribed", async function() {
|
||||
eventSubscriber.subscribe(() => {});
|
||||
@@ -1584,6 +1614,34 @@ describe("lib/subscriptions", function() {
|
||||
assert.equal(snap2.has(2), true);
|
||||
});
|
||||
|
||||
it("should fetch everything again when a new session starts", async function() {
|
||||
subscriber.subscribe(() => {});
|
||||
await events.once(mockControl.connector, "send");
|
||||
await subscriber._handleEvent(new MapEvent([
|
||||
{ id: 1, updatedAtMs: 10, isDeleted: false },
|
||||
]));
|
||||
const [before] = subscriber.getSnapshot();
|
||||
mockControl.connector.sentMessages.length = 0;
|
||||
|
||||
mockControl.connector.emit("connect");
|
||||
assert.equal(subscriber.values.size, 0);
|
||||
assert.notStrictEqual(subscriber.getSnapshot()[0], before);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const requests = mockControl.connector.sentMessages.filter(m => m.name === "SubscriptionRequest");
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].data.lastRequestTimeMs, -1);
|
||||
});
|
||||
|
||||
it("should keep values when a session is resumed", async function() {
|
||||
await subscriber._handleEvent(new MapEvent([
|
||||
{ id: 1, updatedAtMs: 10, isDeleted: false },
|
||||
]));
|
||||
subscriber.handleConnectionEvent("drop");
|
||||
subscriber.handleConnectionEvent("resume");
|
||||
assert.equal(subscriber.values.size, 1);
|
||||
assert.equal(subscriber.lastUpdatedMs, 10);
|
||||
});
|
||||
|
||||
it("should handle multiple updates correctly", async function() {
|
||||
await subscriber._handleEvent(new MapEvent([
|
||||
{ id: 1, updatedAtMs: 10, isDeleted: false },
|
||||
|
||||
@@ -52,6 +52,20 @@ describe("messages/controller", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoleDeleteRequest", function() {
|
||||
it("removes the role from users and sends a user update", async function() {
|
||||
const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", []));
|
||||
const user = controller.users.getOrCreateUser("player");
|
||||
user.addRole(id);
|
||||
const updated = [];
|
||||
controller.users.records.on("update", users => updated.push(...users));
|
||||
await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id));
|
||||
assert.equal(controller.roles.has(id), false);
|
||||
assert.equal(user.roleIds.has(id), false);
|
||||
assert(updated.some(u => u.name === "player" && !u.roleIds.has(id)), "no user update sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ControllerConfigGetRequest", function() {
|
||||
it("runs", async function() {
|
||||
const config = await controlConnection.handleControllerConfigGetRequest(
|
||||
@@ -186,6 +200,34 @@ describe("messages/controller", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Host pairing", function() {
|
||||
const details = [
|
||||
new lib.HostPairingDetails("ABC-DEF", "Host A", "10.0.0.1", "2.0.0", 100, 200),
|
||||
new lib.HostPairingDetails("GHJ-KMN", "Host B", "2001:db8::1", "", 0, 300, true),
|
||||
];
|
||||
|
||||
it("round trips HostPairingListRequest responses", function() {
|
||||
const validate = lib.compile(lib.HostPairingListRequest.Response.jsonSchema);
|
||||
const json = JSON.parse(JSON.stringify(details));
|
||||
assert(validate(json), JSON.stringify(validate.errors));
|
||||
assert.deepEqual(lib.HostPairingListRequest.Response.fromJSON(json), details);
|
||||
});
|
||||
it("round trips HostPairingUpdatesEvent", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostPairingUpdatesEvent, [[[]], [details]]);
|
||||
});
|
||||
it("round trips HostPairingApproveRequest", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostPairingApproveRequest, testMatrix(
|
||||
["ABC-DEF", "abcdef"], // code
|
||||
[undefined, "Renamed"], // name
|
||||
));
|
||||
const validate = lib.compile(lib.HostPairingApproveRequest.Response.jsonSchema);
|
||||
assert(validate(1234));
|
||||
});
|
||||
it("round trips HostPairingRejectRequest", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostPairingRejectRequest, [["ABC-DEF"], ["abcdef"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceConfigGetRequest", function() {
|
||||
it("runs", async function() {
|
||||
const config = await controlConnection.handleInstanceConfigGetRequest(
|
||||
|
||||
@@ -18,6 +18,12 @@ describe("messages/host", function() {
|
||||
host = new Host(hostConnector, hostConfig, []);
|
||||
});
|
||||
|
||||
describe("HostRevokeRequest", function() {
|
||||
it("is round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(lib.HostRevokeRequest, [[1], [2 ** 31 - 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostUpdateRequest", function() {
|
||||
it("runs", async function() {
|
||||
host.config.set("host.allow_remote_updates", true);
|
||||
|
||||
@@ -38,20 +38,46 @@ describe("messages/plugin", function() {
|
||||
describe("PluginDetails", function() {
|
||||
it("has round trip json serialisation", function() {
|
||||
testRoundTripJsonSerialisable(lib.PluginDetails, [
|
||||
["Name", "Title", "Version", true, true],
|
||||
[
|
||||
"Name", "Title", "Version", false, false, "Description", "Npm Package",
|
||||
"Installed Version", true, true, "Dependency Name", true, true,
|
||||
],
|
||||
["Name", "Title", "Version", "loaded"],
|
||||
["Name", "Title", "Version", "removedLoaded", "Description", "Npm Package", "Installed Version", "Dep"],
|
||||
]);
|
||||
});
|
||||
it("sends loaded and enabled for 2.0.3 and older", function() {
|
||||
const cases = [
|
||||
["loaded", true, true],
|
||||
["disabled", false, false],
|
||||
["unloadOnRestart", true, false],
|
||||
["loadOnRestart", false, true],
|
||||
["noEntrypoint", false, false],
|
||||
["pendingInstall", false, true],
|
||||
["removed", false, false],
|
||||
["removedLoaded", true, false],
|
||||
["nameTaken", false, false],
|
||||
];
|
||||
for (const [state, loaded, enabled] of cases) {
|
||||
const json = JSON.parse(JSON.stringify(new lib.PluginDetails("Name", "Title", "Version", state)));
|
||||
assert.deepEqual([json.loaded, json.enabled], [loaded, enabled], state);
|
||||
}
|
||||
});
|
||||
it("reads the state of plugins listed by 2.0.3 and older", function() {
|
||||
const cases = [
|
||||
[true, true, "loaded"],
|
||||
[true, false, "unloadOnRestart"],
|
||||
[false, true, "loadOnRestart"],
|
||||
[false, false, "disabled"],
|
||||
];
|
||||
for (const [loaded, enabled, state] of cases) {
|
||||
const json = { name: "Name", title: "Title", version: "1.0.0", loaded, enabled };
|
||||
assert.equal(lib.PluginDetails.fromJSON(json).state, state);
|
||||
}
|
||||
});
|
||||
it("can be created from NodeEnvInfo interface", function() {
|
||||
const pluginDetails = lib.PluginDetails.fromNodeEnvInfo({
|
||||
name: "Name", title: "Title", version: "Version",
|
||||
description: "Description", npmPackage: "Npm Package",
|
||||
}, true, true);
|
||||
}, "loaded");
|
||||
assert.deepEqual(pluginDetails, new lib.PluginDetails(
|
||||
"Name", "Title", "Version", true, true,
|
||||
"Name", "Title", "Version", "loaded",
|
||||
"Description", "Npm Package",
|
||||
));
|
||||
});
|
||||
@@ -60,8 +86,8 @@ describe("messages/plugin", function() {
|
||||
"package", { version: "1.0.0", description: "Description" }, "package", "package",
|
||||
);
|
||||
assert.deepEqual(pluginDetails, new lib.PluginDetails(
|
||||
"package", "package", "1.0.0", false, false,
|
||||
"Description", "package", "1.0.0", true, undefined, "package",
|
||||
"package", "package", "1.0.0", "pendingInstall",
|
||||
"Description", "package", "1.0.0", "package",
|
||||
));
|
||||
const invalid = lib.PluginDetails.fromPackageJson("package", { version: "1.0.0", description: 1 });
|
||||
assert.equal(invalid.description, undefined);
|
||||
@@ -99,7 +125,7 @@ describe("messages/plugin", function() {
|
||||
|
||||
const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest());
|
||||
assert.deepEqual(response, [new lib.PluginDetails(
|
||||
"Name", "Title", "Version", true, true,
|
||||
"Name", "Title", "Version", "loaded",
|
||||
"Description", "Npm Package", "Version",
|
||||
)]);
|
||||
});
|
||||
@@ -114,7 +140,7 @@ describe("messages/plugin", function() {
|
||||
|
||||
const response = await host.handlePluginListRequest(new lib.PluginListRequest());
|
||||
assert.deepEqual(response, [new lib.PluginDetails(
|
||||
"Name", "Title", "Version", true, true,
|
||||
"Name", "Title", "Version", "loaded",
|
||||
"Description", "Npm Package", "Version",
|
||||
)]);
|
||||
});
|
||||
|
||||
@@ -176,6 +176,8 @@ export class MockController {
|
||||
["controller.auth_secret", "TestSecretDoNotUse"],
|
||||
["controller.proxy_stream_timeout", 1],
|
||||
["controller.metrics_timeout", 1],
|
||||
["controller.host_pairing_timeout", 3600],
|
||||
["controller.host_pairing_max_pending", 50],
|
||||
]);
|
||||
this.config = {
|
||||
get: (name) => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { MockConnector, MockControl } from "../mock.js";
|
||||
import "../setup_logging.js";
|
||||
|
||||
import { HostPairingSubscriber } from "../../packages/web_ui/src/util/host_pairing.ts";
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
describe("web_ui/util/host_pairing", function() {
|
||||
describe("class HostPairingSubscriber", function() {
|
||||
let mockControl;
|
||||
let subscriber;
|
||||
|
||||
beforeEach(function() {
|
||||
mockControl = new MockControl(new MockConnector(addr({ controlId: 0 }), addr("controller")));
|
||||
subscriber = new HostPairingSubscriber(mockControl, () => true);
|
||||
});
|
||||
|
||||
function pairing(code, updatedAtMs) {
|
||||
return new lib.HostPairingDetails(code, `Host ${code}`, "192.0.2.1", "", updatedAtMs, updatedAtMs);
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function sentRequests() {
|
||||
return mockControl.connector.sentMessages.filter(message => message.name === "SubscriptionRequest");
|
||||
}
|
||||
|
||||
it("should clear requests when subscribing again after the last unsubscribe", async function() {
|
||||
const unsubscribe = subscriber.subscribe(() => {});
|
||||
await events.once(mockControl.connector, "send");
|
||||
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
|
||||
unsubscribe();
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
mockControl.connector.sentMessages.length = 0;
|
||||
|
||||
subscriber.subscribe(() => {});
|
||||
assert.equal(subscriber.values.size, 0);
|
||||
await settle();
|
||||
assert.equal(sentRequests().length, 1);
|
||||
assert.equal(sentRequests()[0].data.lastRequestTimeMs, -1);
|
||||
});
|
||||
|
||||
it("should keep requests when a page that subscribes replaces another", async function() {
|
||||
const unsubscribe = subscriber.subscribe(() => {});
|
||||
await events.once(mockControl.connector, "send");
|
||||
await subscriber._handleEvent(new lib.HostPairingUpdatesEvent([pairing("ABC-DEF", 10)]));
|
||||
unsubscribe();
|
||||
subscriber.subscribe(() => {});
|
||||
assert.equal(subscriber.values.size, 1);
|
||||
assert.equal(subscriber.lastUpdatedMs, 10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,11 @@ describe("web_ui/util/plugin_status", function() {
|
||||
return { name, isController: false, address: { hostId: name.length }, plugins };
|
||||
}
|
||||
function loaded(npmPackage, version) {
|
||||
return new lib.PluginDetails("plugin", "Plugin", version, true, true, undefined, npmPackage, version);
|
||||
return new lib.PluginDetails("plugin", "Plugin", version, "loaded", undefined, npmPackage, version);
|
||||
}
|
||||
function pending(packageName, npmPackage, version) {
|
||||
return new lib.PluginDetails(
|
||||
packageName, packageName, version, false, false, undefined, npmPackage, version, true,
|
||||
packageName, packageName, version, "pendingInstall", undefined, npmPackage, version,
|
||||
);
|
||||
}
|
||||
function skippedText(result) {
|
||||
@@ -50,7 +50,7 @@ describe("web_ui/util/plugin_status", function() {
|
||||
it("should not update a loaded package that isn't from npm", function() {
|
||||
const result = plan([
|
||||
machine("a", [loaded("plugin-aliased", "1.0.0")]),
|
||||
machine("b", [new lib.PluginDetails("plugin-aliased", "Plugin", "1.0.0", true, true)]),
|
||||
machine("b", [new lib.PluginDetails("plugin-aliased", "Plugin", "1.0.0", "loaded")]),
|
||||
]);
|
||||
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
|
||||
assert.deepEqual(skippedText(result), [{ kind: "update", text: "b (not installed from npm)" }]);
|
||||
@@ -58,7 +58,7 @@ describe("web_ui/util/plugin_status", function() {
|
||||
|
||||
it("should not send a specific version to hosts that can't parse it", function() {
|
||||
// Hosts from before installedVersion was reported take name@version as a package name
|
||||
const old = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased");
|
||||
const old = new lib.PluginDetails("plugin", "Plugin", "1.0.0", "loaded", undefined, "plugin-aliased");
|
||||
const result = plan([
|
||||
machine("a", [loaded("plugin-aliased", "1.0.0")]),
|
||||
machine("old", [old]),
|
||||
@@ -70,7 +70,7 @@ describe("web_ui/util/plugin_status", function() {
|
||||
});
|
||||
|
||||
it("should not install over a loaded alias that has the package name", function() {
|
||||
const alias = new lib.PluginDetails("real_plugin", "Real", "1.0.0", true, true);
|
||||
const alias = new lib.PluginDetails("real_plugin", "Real", "1.0.0", "loaded");
|
||||
alias.dependencyName = "plugin-aliased";
|
||||
const statuses = computePluginStatuses([
|
||||
machine("a", [loaded("plugin-aliased", "1.0.0")]),
|
||||
@@ -110,7 +110,7 @@ describe("web_ui/util/plugin_status", function() {
|
||||
describe("planMachineUpdate()", function() {
|
||||
it("should plan for the given machine only", function() {
|
||||
const loaded = new lib.PluginDetails(
|
||||
"plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
|
||||
"plugin", "Plugin", "1.0.0", "loaded", undefined, "plugin-aliased", "1.0.0",
|
||||
);
|
||||
const statuses = computePluginStatuses([
|
||||
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loaded] },
|
||||
@@ -136,7 +136,7 @@ describe("web_ui/util/plugin_status", function() {
|
||||
}
|
||||
function loadedPlugin(version = "1.0.0", installedVersion = version) {
|
||||
return new lib.PluginDetails(
|
||||
"plugin", "Plugin", version, true, true, undefined, "plugin", installedVersion,
|
||||
"plugin", "Plugin", version, "loaded", undefined, "plugin", installedVersion,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,24 +146,22 @@ describe("web_ui/util/plugin_status", function() {
|
||||
assert.deepEqual(labels([]), ["Not installed"]);
|
||||
assert.deepEqual(labels([loadedPlugin()], { offline: true }), ["Offline"]);
|
||||
const disabled = new lib.PluginDetails(
|
||||
"plugin", "Plugin", "1.0.0", false, false, undefined, "plugin", "1.0.0",
|
||||
"plugin", "Plugin", "1.0.0", "disabled", undefined, "plugin", "1.0.0",
|
||||
);
|
||||
assert.deepEqual(labels([disabled]), ["Disabled"]);
|
||||
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true);
|
||||
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", "loaded");
|
||||
assert.deepEqual(labels([local]), ["Not installed from npm"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePluginStatuses()", function() {
|
||||
it("should not expect a plugin without an entrypoint for the machine to run after restart", function() {
|
||||
const plugin = new lib.PluginDetails("controller_only", "Plugin", "1.0.0", false, false);
|
||||
plugin.noEntrypoint = true;
|
||||
const plugin = new lib.PluginDetails("controller_only", "Plugin", "1.0.0", "noEntrypoint");
|
||||
const [status] = computePluginStatuses([
|
||||
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
|
||||
]).values();
|
||||
const [entry] = status.machines;
|
||||
assert.equal(entry.noEntrypoint, true);
|
||||
assert.equal(entry.disabled, false);
|
||||
assert.equal(entry.state, "noEntrypoint");
|
||||
assert.equal(entry.running, undefined);
|
||||
assert.equal(entry.afterRestart, undefined);
|
||||
});
|
||||
|
||||
@@ -9,62 +9,64 @@ const target = "2.0.0";
|
||||
const versions = ["1.0.0", "2.0.0", "3.0.0"];
|
||||
|
||||
function details(fields) {
|
||||
const plugin = new lib.PluginDetails(
|
||||
fields.name, fields.name, fields.version, fields.loaded, fields.enabled, undefined, fields.npmPackage,
|
||||
fields.installedVersion, fields.pendingInstall, fields.removed, fields.dependencyName, fields.noEntrypoint,
|
||||
fields.nameTaken,
|
||||
return new lib.PluginDetails(
|
||||
fields.name, fields.name, fields.version, fields.state, undefined, fields.npmPackage,
|
||||
fields.installedVersion, fields.dependencyName,
|
||||
);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function* states() {
|
||||
yield { label: "absent", plugins: [] };
|
||||
for (const version of versions) {
|
||||
for (const installed of [version, ...versions.filter(other => other !== version)]) {
|
||||
for (const enabled of [true, false]) {
|
||||
for (const loaded of enabled ? [true] : [true, false]) {
|
||||
const state = `${enabled ? "enabled" : "disabled"}${loaded ? " loaded" : ""}`;
|
||||
yield {
|
||||
label: `npm ${version} on disk ${installed} ${state}`,
|
||||
entry: { source: "npm", version, installed, enabled, loaded },
|
||||
plugins: [details({
|
||||
name: "plugin", version, loaded, enabled, npmPackage: "pkg",
|
||||
installedVersion: installed, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
}
|
||||
for (const [state, loaded, enabled] of [
|
||||
["loaded", true, true],
|
||||
["unloadOnRestart", true, false],
|
||||
["loadOnRestart", false, true],
|
||||
["disabled", false, false],
|
||||
]) {
|
||||
yield {
|
||||
label: `npm ${version} on disk ${installed} ${state}`,
|
||||
entry: { source: "npm", version, installed, enabled, loaded },
|
||||
plugins: [details({
|
||||
name: "plugin", version, state, npmPackage: "pkg",
|
||||
installedVersion: installed, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
}
|
||||
}
|
||||
yield {
|
||||
label: `npm ${version} removed`,
|
||||
entry: { source: "npm", version, removed: true },
|
||||
plugins: [details({
|
||||
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
|
||||
removed: true, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
for (const loaded of [true, false]) {
|
||||
yield {
|
||||
label: `npm ${version} removed${loaded ? " while loaded" : ""}`,
|
||||
entry: { source: "npm", version, removed: true, loaded },
|
||||
plugins: [details({
|
||||
name: "plugin", version, state: loaded ? "removedLoaded" : "removed", npmPackage: "pkg",
|
||||
dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
}
|
||||
yield {
|
||||
label: `npm ${version} no entrypoint`,
|
||||
entry: { source: "npm", version, installed: version, noEntrypoint: true },
|
||||
plugins: [details({
|
||||
name: "plugin", version, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg", noEntrypoint: true,
|
||||
name: "plugin", version, state: "noEntrypoint", npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: `npm ${version} pending`,
|
||||
entry: { source: "npm", version, installed: version, pending: true },
|
||||
plugins: [details({
|
||||
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
|
||||
name: "pkg", version, state: "pendingInstall", npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: `alias ${version} pending`,
|
||||
entry: { source: "alias", version, installed: version, pending: true },
|
||||
plugins: [details({
|
||||
name: "pkg", version, loaded: false, enabled: false,
|
||||
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
|
||||
name: "pkg", version, state: "pendingInstall",
|
||||
installedVersion: version, dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
@@ -72,12 +74,12 @@ function* states() {
|
||||
entry: { source: "npm", version, installed: target, replaced: true },
|
||||
plugins: [
|
||||
details({
|
||||
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
|
||||
removed: true, dependencyName: "pkg",
|
||||
name: "plugin", version, state: "removedLoaded", npmPackage: "pkg",
|
||||
dependencyName: "pkg",
|
||||
}),
|
||||
details({
|
||||
name: "pkg", version: target, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: target, pendingInstall: true, dependencyName: "pkg",
|
||||
name: "pkg", version: target, state: "pendingInstall", npmPackage: "pkg",
|
||||
installedVersion: target, dependencyName: "pkg",
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -86,7 +88,7 @@ function* states() {
|
||||
label: "alias loaded as pkg",
|
||||
taken: "dependency",
|
||||
plugins: [details({
|
||||
name: "real_plugin", version: "1.0.0", loaded: true, enabled: true,
|
||||
name: "real_plugin", version: "1.0.0", state: "loaded",
|
||||
installedVersion: "1.0.0", dependencyName: "pkg",
|
||||
})],
|
||||
};
|
||||
@@ -95,10 +97,10 @@ function* states() {
|
||||
label: `npm ${version} skipped as its name is taken`,
|
||||
taken: "name",
|
||||
plugins: [
|
||||
details({ name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0" }),
|
||||
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
|
||||
details({
|
||||
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg", nameTaken: true,
|
||||
name: "pkg", version, state: "nameTaken", npmPackage: "pkg",
|
||||
installedVersion: version, dependencyName: "pkg",
|
||||
}),
|
||||
],
|
||||
entry: { source: "npm", version, installed: version },
|
||||
@@ -108,17 +110,17 @@ function* states() {
|
||||
label: "local plugin with the same name",
|
||||
taken: "name",
|
||||
plugins: [details({
|
||||
name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0",
|
||||
name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0",
|
||||
})],
|
||||
};
|
||||
yield {
|
||||
label: "local plugin with the same name and pkg pending",
|
||||
taken: "name",
|
||||
plugins: [
|
||||
details({ name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0" }),
|
||||
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
|
||||
details({
|
||||
name: "pkg", version: "1.0.0", loaded: false, enabled: false, npmPackage: "pkg",
|
||||
installedVersion: "1.0.0", pendingInstall: true, dependencyName: "pkg",
|
||||
name: "pkg", version: "1.0.0", state: "pendingInstall", npmPackage: "pkg",
|
||||
installedVersion: "1.0.0", dependencyName: "pkg",
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -145,7 +147,7 @@ function expected(state) {
|
||||
}
|
||||
let running;
|
||||
let afterRestart;
|
||||
if (entry?.loaded || entry?.removed || entry?.replaced) {
|
||||
if (entry?.loaded || entry?.replaced) {
|
||||
running = entry.version;
|
||||
}
|
||||
if (entry && !entry.removed && !entry.noEntrypoint && (entry.pending || entry.replaced || entry.enabled)) {
|
||||
@@ -159,7 +161,7 @@ function semverGte(a, b) {
|
||||
}
|
||||
|
||||
const reference = { name: "ref", isController: true, address: "controller", plugins: [details({
|
||||
name: "plugin", version: "1.0.0", loaded: true, enabled: true, npmPackage: "pkg",
|
||||
name: "plugin", version: "1.0.0", state: "loaded", npmPackage: "pkg",
|
||||
installedVersion: "1.0.0", dependencyName: "pkg",
|
||||
})] };
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getResourceStatus } from "../../packages/web_ui/src/util/useResourceState.ts";
|
||||
|
||||
describe("web_ui/util/useResourceState", function() {
|
||||
describe("getResourceStatus()", function() {
|
||||
const error = new Error("Permission denied");
|
||||
|
||||
it("should be ok when the resource exists", function() {
|
||||
assert.equal(getResourceStatus(true, true, true, null), "ok");
|
||||
});
|
||||
it("should keep showing a resource after the subscription stopped syncing", function() {
|
||||
assert.equal(getResourceStatus(true, true, false, null), "ok");
|
||||
assert.equal(getResourceStatus(true, true, false, error), "ok");
|
||||
});
|
||||
it("should be loading until the subscription has synced", function() {
|
||||
assert.equal(getResourceStatus(false, false, false, null), "loading");
|
||||
});
|
||||
it("should be loading while resyncing a resource that was seen", function() {
|
||||
assert.equal(getResourceStatus(false, true, false, null), "loading");
|
||||
});
|
||||
it("should be missing when synced without having seen the resource", function() {
|
||||
assert.equal(getResourceStatus(false, false, true, null), "missing");
|
||||
});
|
||||
it("should be deleted when synced after having seen the resource", function() {
|
||||
assert.equal(getResourceStatus(false, true, true, null), "deleted");
|
||||
});
|
||||
it("should be error when the subscription failed", function() {
|
||||
assert.equal(getResourceStatus(false, false, false, error), "error");
|
||||
assert.equal(getResourceStatus(false, true, false, error), "error");
|
||||
});
|
||||
it("should ignore the error of an earlier attempt once synced", function() {
|
||||
assert.equal(getResourceStatus(false, false, true, error), "missing");
|
||||
assert.equal(getResourceStatus(false, true, true, error), "deleted");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user