mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 07:04:53 +09:00
Merge remote-tracking branch 'origin/master' into fix/stream-reject-closes-connection
This commit is contained in:
142 files changed
+8147
-1306
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;
|
||||
|
||||
@@ -153,6 +156,142 @@ describe("controller/src/ControlConnection", function() {
|
||||
assert.equal(clone.config.get("instance.name"), "clone");
|
||||
assert.equal(clone.config.get("instance.assigned_host"), null);
|
||||
});
|
||||
|
||||
it("creates an instance inheriting from a template", async function() {
|
||||
await create({ "instance.id": 4001, "instance.name": "template", "instance.is_template": true });
|
||||
mockController.instances.getMutable(4001).config.set("factorio.version", "2.0");
|
||||
|
||||
await create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 });
|
||||
const child = mockController.instances.get(4002);
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
assert.equal(child.config.isInherited("instance.name"), false);
|
||||
});
|
||||
|
||||
it("rejects a template that is not a template", async function() {
|
||||
await create({ "instance.id": 4001, "instance.name": "plain" });
|
||||
await assert.rejects(
|
||||
create({ "instance.id": 4002, "instance.name": "child", "instance.template_id": 4001 }),
|
||||
new lib.ValidationError("Instance plain is not a template"),
|
||||
);
|
||||
assert.equal(mockController.instances.has(4002), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance config set handlers", function() {
|
||||
let mockController;
|
||||
let connection;
|
||||
|
||||
beforeEach(async function() {
|
||||
mockController = new MockController();
|
||||
mockController.mockConfigEntries.set("controller.name", "Test");
|
||||
mockController.instances = new InstanceManager(new lib.SubscribableDatastore(), mockController);
|
||||
mockController.instanceConfigUpdated = async () => {};
|
||||
connection = {
|
||||
_controller: mockController,
|
||||
checkStagedInheritance: ControlConnection.prototype.checkStagedInheritance,
|
||||
};
|
||||
for (const [id, fields] of [
|
||||
[1, { "instance.is_template": true, "factorio.version": "2.0" }],
|
||||
[2, { "instance.template_id": 1 }],
|
||||
]) {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": id, "instance.name": `i${id}`, ...fields,
|
||||
});
|
||||
if (fields["instance.template_id"]) {
|
||||
mockController.instances.applyTemplate(
|
||||
config, ["instance.id", "instance.name", "instance.template_id"],
|
||||
);
|
||||
}
|
||||
await mockController.instances.createInstance(config);
|
||||
}
|
||||
});
|
||||
|
||||
function call(handler, request) {
|
||||
return ControlConnection.prototype[handler].call(connection, request);
|
||||
}
|
||||
async function create(config, cloneFromId) {
|
||||
await ControlConnection.prototype.handleInstanceCreateRequest.call(
|
||||
connection, new lib.InstanceCreateRequest(config, cloneFromId),
|
||||
);
|
||||
}
|
||||
const setField = (...args) => new lib.InstanceConfigSetFieldRequest(...args);
|
||||
const setFields = (...args) => new lib.InstanceConfigSetRequest(...args);
|
||||
|
||||
it("stops inheriting a field set to the inherited value", async function() {
|
||||
await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.0"));
|
||||
assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false);
|
||||
});
|
||||
|
||||
it("stops inheriting a field set through set request", async function() {
|
||||
await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.version": "2.0" }));
|
||||
assert.equal(mockController.instances.get(2).config.isInherited("factorio.version"), false);
|
||||
});
|
||||
|
||||
it("stops inheriting a property set through set-prop", async function() {
|
||||
await call(
|
||||
"handleInstanceConfigSetPropRequest",
|
||||
new lib.InstanceConfigSetPropRequest(2, "factorio.settings", "max_players", 0),
|
||||
);
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
|
||||
it("rejects template_id pointing at a non-template", async function() {
|
||||
await assert.rejects(
|
||||
call("handleInstanceConfigSetFieldRequest", setField(1, "instance.template_id", "2")),
|
||||
new lib.ValidationError("Instance i2 is not a template"),
|
||||
);
|
||||
assert.equal(mockController.instances.get(1).config.get("instance.template_id"), null);
|
||||
});
|
||||
|
||||
it("rejects unmarking a template with inheritors through set request", async function() {
|
||||
await assert.rejects(
|
||||
call("handleInstanceConfigSetRequest", setFields(1, { "instance.is_template": "false" })),
|
||||
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
|
||||
);
|
||||
assert.equal(mockController.instances.get(1).config.get("instance.is_template"), true);
|
||||
});
|
||||
|
||||
it("stops inheriting only the properties set through set request", async function() {
|
||||
await call("handleInstanceConfigSetRequest", setFields(2, { "factorio.settings": { max_players: 3 } }));
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
|
||||
it("inherits a property again", async function() {
|
||||
await call("handleInstanceConfigSetPropRequest", new lib.InstanceConfigSetPropRequest(
|
||||
2, "factorio.settings", "max_players", 3,
|
||||
));
|
||||
await call(
|
||||
"handleInstanceConfigInheritFieldRequest",
|
||||
new lib.InstanceConfigInheritFieldRequest(2, "factorio.settings", "max_players"),
|
||||
);
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.get("factorio.settings").max_players, 0);
|
||||
assert.equal(config.isInherited("factorio.settings", "max_players"), true);
|
||||
});
|
||||
|
||||
it("creates a template from an existing instance", async function() {
|
||||
await create({ "instance.id": 3, "instance.name": "copy", "instance.is_template": true }, 2);
|
||||
const config = mockController.instances.get(3).config;
|
||||
assert.equal(config.get("instance.is_template"), true);
|
||||
assert.equal(config.get("instance.template_id"), 1);
|
||||
assert.equal(config.get("factorio.version"), "2.0");
|
||||
});
|
||||
|
||||
it("inherits a field again", async function() {
|
||||
await call("handleInstanceConfigSetFieldRequest", setField(2, "factorio.version", "2.1"));
|
||||
await call(
|
||||
"handleInstanceConfigInheritFieldRequest",
|
||||
new lib.InstanceConfigInheritFieldRequest(2, "factorio.version"),
|
||||
);
|
||||
const config = mockController.instances.get(2).config;
|
||||
assert.equal(config.get("factorio.version"), "2.0");
|
||||
assert.equal(config.isInherited("factorio.version"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close cleanup", function() {
|
||||
@@ -272,4 +411,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.records.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.records.size, 0);
|
||||
assert(audits.some(msg => msg.startsWith(`User admin rejected host Host A with code ${code}`)));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,10 @@ class MockInstanceConfig extends EventEmitter {
|
||||
this._data.set(field, value);
|
||||
this.emit("fieldChanged", field, value, prev);
|
||||
}
|
||||
|
||||
isInherited() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("controller/src/Controller", function() {
|
||||
@@ -89,6 +93,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" }]);
|
||||
@@ -129,6 +202,43 @@ describe("controller/src/Controller", function() {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe(".validateExports()", function() {
|
||||
let dir;
|
||||
before(async function() {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-exports-"));
|
||||
await fs.writeFile(path.join(dir, "locale.abc.json"), "[]");
|
||||
await fs.writeFile(path.join(dir, "metadata.abc.json"), "{}");
|
||||
});
|
||||
after(async function() {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
it("should drop export manifests with missing or invalid files", async function() {
|
||||
const provider = new lib.MemoryDatastoreProvider();
|
||||
const modPacks = new lib.SubscribableDatastore(provider, new Map());
|
||||
const packs = [
|
||||
{ id: 1, assets: { locale: "locale.abc.json", metadata: "metadata.abc.json" } },
|
||||
{ id: 2, assets: { locale: "locale.abc.json", metadata: "metadata.def.json" } },
|
||||
{ id: 3, assets: { locale: "../locale.abc.json" } },
|
||||
{ id: 4 },
|
||||
].map(({ id, assets }) => {
|
||||
const modPack = ModPack.fromJSON({ id, name: `pack ${id}` });
|
||||
if (assets) {
|
||||
modPack.exportManifest = new lib.ExportManifest(assets);
|
||||
}
|
||||
return modPack;
|
||||
});
|
||||
modPacks.setMany(packs);
|
||||
const updates = [];
|
||||
modPacks.on("update", updated => updates.push(...updated));
|
||||
|
||||
await Controller.validateExports(modPacks, dir);
|
||||
assert.deepEqual(modPacks.get(1).exportManifest.assets, packs[0].exportManifest.assets);
|
||||
assert.equal(modPacks.get(2).exportManifest, undefined);
|
||||
assert.equal(modPacks.get(3).exportManifest, undefined);
|
||||
assert.equal(modPacks.get(4).exportManifest, undefined);
|
||||
assert.deepEqual(updates.map(modPack => modPack.id), [2, 3]);
|
||||
});
|
||||
});
|
||||
describe(".loadJsonObject()", function() {
|
||||
let dir;
|
||||
before(async function() {
|
||||
|
||||
@@ -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.records.set(record);
|
||||
connector = new MockConnector(addr("controller"), addr({ hostId: 10 }));
|
||||
updates = [];
|
||||
controller.hosts.records.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.records.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.records.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,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, HostManager, HostRecord } from "@clusterio/controller";
|
||||
|
||||
import "../setup_logging.js";
|
||||
|
||||
describe("controller/src/HostManager", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {HostManager} */
|
||||
let hosts;
|
||||
|
||||
beforeEach(function() {
|
||||
const config = new lib.ControllerConfig("controller");
|
||||
config.set("controller.auth_secret", Buffer.from("TestSecretDoNotUse").toString("base64"));
|
||||
controller = new Controller(lib.logger, [], config);
|
||||
hosts = controller.hosts;
|
||||
});
|
||||
|
||||
it("should be the host manager of the controller", function() {
|
||||
assert(hosts instanceof HostManager);
|
||||
});
|
||||
|
||||
describe(".has() / .get() / .getMutable() / .values() / .getForRequest()", function() {
|
||||
it("should return the host when it exists", function() {
|
||||
const record = new HostRecord(5, "five", "", new Map());
|
||||
hosts.records.set(record);
|
||||
assert.equal(hosts.has(5), true);
|
||||
assert.equal(hosts.get(5), record);
|
||||
assert.equal(hosts.getMutable(5), record);
|
||||
assert.deepEqual([...hosts.values()], [record]);
|
||||
assert.equal(hosts.getForRequest(5), record);
|
||||
});
|
||||
it("should return undefined or throw when the host does not exist", function() {
|
||||
assert.equal(hosts.has(5), false);
|
||||
assert.equal(hosts.get(5), undefined);
|
||||
assert.equal(hosts.getMutable(5), undefined);
|
||||
assert.throws(() => hosts.getForRequest(5), new lib.NotFoundError("Unknown host id (5)"));
|
||||
});
|
||||
});
|
||||
|
||||
describe(".createHost()", function() {
|
||||
let originalRandomInt;
|
||||
afterEach(function() {
|
||||
if (originalRandomInt) {
|
||||
crypto.randomInt = originalRandomInt;
|
||||
originalRandomInt = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("should create a host record that its tokens are valid for", function() {
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const host = hosts.createHost("Host A");
|
||||
|
||||
assert(host instanceof HostRecord);
|
||||
assert(Number.isInteger(host.id), "id is not an integer");
|
||||
assert(host.id >= 1 && host.id < 2 ** 31, `id ${host.id} out of range`);
|
||||
assert.equal(hosts.get(host.id), host);
|
||||
assert.equal(host.name, "Host A");
|
||||
assert.equal(host.connected, false);
|
||||
assert(host.tokenValidAfter >= before, "tokenValidAfter predates the call");
|
||||
assert(host.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future");
|
||||
assert.equal(hosts.verifyHostToken(hosts.signHostToken(host.id)), host);
|
||||
});
|
||||
it("should not reuse the id of an existing host", function() {
|
||||
hosts.records.set(new HostRecord(5, "existing", "", new Map()));
|
||||
const values = [5, 5, 6];
|
||||
originalRandomInt = crypto.randomInt;
|
||||
crypto.randomInt = (...args) => (values.length ? values.shift() : originalRandomInt(...args));
|
||||
const host = hosts.createHost("Host A");
|
||||
assert.equal(host.id, 6);
|
||||
assert.equal(hosts.get(5).name, "existing");
|
||||
assert.equal(hosts.get(6).name, "Host A");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".signHostToken()", function() {
|
||||
it("should return a host token for the id", function() {
|
||||
const payload = jwt.verify(hosts.signHostToken(5), controller.authSecret);
|
||||
const { iat, ...rest } = payload;
|
||||
assert.equal(typeof iat, "number");
|
||||
assert.deepEqual(rest, { aud: "host", host: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe(".verifyHostToken()", function() {
|
||||
beforeEach(function() {
|
||||
hosts.records.set(new HostRecord(5, "five", "", new Map()));
|
||||
});
|
||||
|
||||
it("should return the mutable host record", function() {
|
||||
assert.equal(hosts.verifyHostToken(hosts.signHostToken(5)), hosts.getMutable(5));
|
||||
});
|
||||
it("should accept a token from before the slave rename", function() {
|
||||
const token = jwt.sign({ aud: "slave", slave: 5 }, controller.authSecret);
|
||||
assert.equal(hosts.verifyHostToken(token).id, 5);
|
||||
});
|
||||
it("should reject a token for an unknown host", function() {
|
||||
assert.throws(() => hosts.verifyHostToken(hosts.signHostToken(6)), /host is not registered/);
|
||||
});
|
||||
it("should reject a token issued before tokenValidAfter", function() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
hosts.getMutable(5).tokenValidAfter = now;
|
||||
const token = jwt.sign({ aud: "host", host: 5, iat: now - 1 }, controller.authSecret);
|
||||
assert.throws(() => hosts.verifyHostToken(token), new Error("invalid token"));
|
||||
});
|
||||
it("should reject user tokens and bad signatures", function() {
|
||||
const userToken = jwt.sign({ aud: "user", user: "test" }, controller.authSecret);
|
||||
assert.throws(() => hosts.verifyHostToken(userToken), /audience invalid/);
|
||||
const forged = jwt.sign({ aud: "host", host: 5 }, "wrong secret");
|
||||
assert.throws(() => hosts.verifyHostToken(forged), /invalid signature/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".revokeHost()", function() {
|
||||
it("should throw for an unknown host", async function() {
|
||||
await assert.rejects(hosts.revokeHost(5, "admin"), new lib.NotFoundError("Unknown host id (5)"));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
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 } 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(".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.records.size, 0);
|
||||
assert.equal(manager.poll(requestId), null);
|
||||
manager.approve(code, undefined, "admin");
|
||||
assert.equal(controller.hosts.records.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.records.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.records.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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -393,4 +393,253 @@ describe("controller/InstanceManager", function () {
|
||||
assert.equal(sentRequest.instanceId, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inheritance", function () {
|
||||
/** @type {InstanceRecord} */
|
||||
let template;
|
||||
/** @type {InstanceRecord} */
|
||||
let child;
|
||||
let updated;
|
||||
|
||||
async function createInheritor(id, templateId, fields = {}) {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": id, "instance.name": `Instance ${id}`, "instance.template_id": templateId, ...fields,
|
||||
});
|
||||
instances.applyTemplate(
|
||||
config, ["instance.id", "instance.name", "instance.template_id", ...Object.keys(fields)]
|
||||
);
|
||||
return await instances.createInstance(config);
|
||||
}
|
||||
|
||||
beforeEach(async function () {
|
||||
template = await instances.createInstance(new lib.InstanceConfig("controller", {
|
||||
"instance.id": 10,
|
||||
"instance.name": "Template",
|
||||
"instance.is_template": true,
|
||||
"factorio.version": "2.0",
|
||||
"factorio.game_port": 34190,
|
||||
"factorio.settings": { name: "Template server", max_players: 5 },
|
||||
}));
|
||||
child = await createInheritor(11, 10, { "factorio.game_port": 34197 });
|
||||
updated = [];
|
||||
controller.instanceConfigUpdated = async (inst) => { updated.push(inst.id); };
|
||||
});
|
||||
|
||||
describe(".applyTemplate()", function () {
|
||||
it("should copy template values and mark them inherited", function () {
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
assert.equal(child.config.get("factorio.game_port"), 34197);
|
||||
assert.equal(child.config.isInherited("factorio.game_port"), false);
|
||||
assert.equal(child.config.isInherited("instance.name"), false);
|
||||
});
|
||||
it("should never inherit ports or the rcon password", function () {
|
||||
template.config.set("factorio.rcon_password", "secret");
|
||||
const config = new lib.InstanceConfig("controller", { "instance.id": 12, "instance.template_id": 10 });
|
||||
instances.applyTemplate(config, ["instance.id", "instance.template_id"]);
|
||||
assert.equal(config.get("factorio.game_port"), null);
|
||||
assert.equal(config.isInherited("factorio.game_port"), false);
|
||||
assert.equal(config.get("factorio.rcon_password"), null);
|
||||
assert.equal(config.isInherited("factorio.rcon_password"), false);
|
||||
});
|
||||
it("should inherit factorio.settings per property with its own server name", function () {
|
||||
const settings = child.config.get("factorio.settings");
|
||||
assert.equal(settings.max_players, 5);
|
||||
assert.equal(settings.name, "TestController - Instance 11");
|
||||
assert.equal(child.config.isInherited("factorio.settings", "max_players"), true);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "name"), false);
|
||||
});
|
||||
it("should keep properties given by the creator as overrides", async function () {
|
||||
const other = await createInheritor(12, 10, { "factorio.settings": { max_players: 8 } });
|
||||
assert.equal(other.config.get("factorio.settings").max_players, 8);
|
||||
assert.equal(other.config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(other.config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template changes", function () {
|
||||
it("should propagate to inherited fields and send the config to the host", function () {
|
||||
template.config.set("factorio.version", "2.1");
|
||||
assert.equal(child.config.get("factorio.version"), "2.1");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
assert.deepEqual(updated, [11]);
|
||||
});
|
||||
it("should mark the inheritor as requiring restart", function () {
|
||||
child.config.restartRequired = false;
|
||||
template.config.set("factorio.version", "2.1");
|
||||
assert.equal(child.config.restartRequired, true);
|
||||
});
|
||||
it("should not touch fields the inheritor has set", function () {
|
||||
child.config.set("factorio.version", "2.1");
|
||||
template.config.set("factorio.version", "2.2");
|
||||
assert.equal(child.config.get("factorio.version"), "2.1");
|
||||
});
|
||||
it("should merge object properties and keep overrides", function () {
|
||||
template.config.setProp("factorio.settings", "max_players", 10);
|
||||
template.config.setProp("factorio.settings", "name", "Renamed");
|
||||
template.config.setProp("factorio.settings", "auto_pause", true);
|
||||
const settings = child.config.get("factorio.settings");
|
||||
assert.equal(settings.max_players, 10);
|
||||
assert.equal(settings.auto_pause, true);
|
||||
assert.equal(settings.name, "TestController - Instance 11");
|
||||
});
|
||||
it("should propagate through several levels", async function () {
|
||||
child.config.set("instance.is_template", true);
|
||||
const grandchild = await createInheritor(12, 11);
|
||||
template.config.setProp("factorio.settings", "max_players", 10);
|
||||
assert.equal(grandchild.config.get("factorio.settings").max_players, 10);
|
||||
assert.deepEqual(updated.sort(), [11, 12]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inheritor changes", function () {
|
||||
it("should stop inheriting a field that is changed", function () {
|
||||
child.config.set("factorio.version", "2.1");
|
||||
assert.equal(child.config.isInherited("factorio.version"), false);
|
||||
});
|
||||
it("should only override the properties changed in an object", function () {
|
||||
child.config.setProp("factorio.settings", "max_players", 3);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "max_players"), false);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), true);
|
||||
});
|
||||
it("should restore the template value of a removed property", function () {
|
||||
child.config.setProp("factorio.settings", "max_players", 3);
|
||||
child.config.setProp("factorio.settings", "max_players");
|
||||
assert.equal(child.config.get("factorio.settings").max_players, 5);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "max_players"), true);
|
||||
});
|
||||
it("should stop inheriting with overrideField() even if the value is unchanged", function () {
|
||||
instances.overrideField(child, "factorio.version");
|
||||
instances.overrideField(child, "factorio.settings", ["auto_pause"]);
|
||||
assert.equal(child.config.isInherited("factorio.version"), false);
|
||||
assert.equal(child.config.isInherited("factorio.settings", "auto_pause"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".inheritField()", function () {
|
||||
it("should restore the template value", function () {
|
||||
child.config.set("factorio.version", "2.1");
|
||||
instances.inheritField(11, "factorio.version");
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
assert.equal(child.config.isInherited("factorio.version"), true);
|
||||
});
|
||||
it("should restore a single property", function () {
|
||||
child.config.setProp("factorio.settings", "max_players", 3);
|
||||
instances.inheritField(11, "factorio.settings", "max_players");
|
||||
assert.equal(child.config.get("factorio.settings").max_players, 5);
|
||||
assert.equal(child.config.get("factorio.settings").name, "TestController - Instance 11");
|
||||
});
|
||||
it("should restore all properties", function () {
|
||||
instances.inheritField(11, "factorio.settings");
|
||||
assert.equal(child.config.get("factorio.settings").name, "Template server");
|
||||
});
|
||||
it("should throw if the instance has no template", function () {
|
||||
assert.throws(
|
||||
() => instances.inheritField(1, "factorio.version"),
|
||||
new lib.ValidationError("Instance does not have a template"),
|
||||
);
|
||||
});
|
||||
it("should throw for fields that cannot be inherited", function () {
|
||||
assert.throws(
|
||||
() => instances.inheritField(11, "instance.name"),
|
||||
new lib.ValidationError("Field instance.name cannot be inherited"),
|
||||
);
|
||||
assert.throws(
|
||||
() => instances.inheritField(11, "factorio.rcon_port"),
|
||||
new lib.ValidationError("Field factorio.rcon_port cannot be inherited"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template_id changes", function () {
|
||||
it("should inherit fields equal to the template or the default", function () {
|
||||
instance.config.set("factorio.version", "2.0");
|
||||
instance.config.set("factorio.verbose_logging", true);
|
||||
template.config.set("instance.auto_start", true);
|
||||
instance.config.set("instance.template_id", 10);
|
||||
assert.equal(instance.config.isInherited("factorio.version"), true);
|
||||
assert.equal(instance.config.isInherited("instance.auto_start"), true);
|
||||
assert.equal(instance.config.get("instance.auto_start"), true);
|
||||
assert.equal(instance.config.isInherited("factorio.verbose_logging"), false);
|
||||
});
|
||||
it("should inherit object properties equal to the template or the default", function () {
|
||||
instance.config.setProp("factorio.settings", "max_players", 0);
|
||||
instance.config.setProp("factorio.settings", "game_password", "secret");
|
||||
instance.config.set("instance.template_id", 10);
|
||||
const settings = instance.config.get("factorio.settings");
|
||||
assert.equal(settings.max_players, 5);
|
||||
assert.equal(settings.game_password, "secret");
|
||||
assert.equal(instance.config.isInherited("factorio.settings", "game_password"), false);
|
||||
assert.equal(instance.config.isInherited("factorio.settings", "max_players"), true);
|
||||
});
|
||||
it("should keep the server name of the instance", function () {
|
||||
instance.config.setProp("factorio.settings", "name", "Template server");
|
||||
instance.config.set("instance.template_id", 10);
|
||||
assert.equal(instance.config.isInherited("factorio.settings", "name"), false);
|
||||
});
|
||||
it("should clear inherited fields when the template is removed", function () {
|
||||
child.config.set("instance.template_id", null);
|
||||
assert.deepEqual(child.config.get("instance.inherited_fields"), {});
|
||||
assert.equal(child.config.get("factorio.version"), "2.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe(".checkInheritance()", function () {
|
||||
it("should accept a valid template", function () {
|
||||
instances.checkInheritance(instance.config, false, 10);
|
||||
});
|
||||
it("should reject a missing template", function () {
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(instance.config, false, 99),
|
||||
new lib.NotFoundError("Instance with ID 99 does not exist"),
|
||||
);
|
||||
});
|
||||
it("should reject an instance that is not a template", function () {
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(instance.config, false, 11),
|
||||
new lib.ValidationError("Instance Instance 11 is not a template"),
|
||||
);
|
||||
});
|
||||
it("should reject cycles", function () {
|
||||
child.config.set("instance.is_template", true);
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(template.config, true, 11),
|
||||
new lib.ValidationError("Templates cannot inherit from themselves"),
|
||||
);
|
||||
});
|
||||
it("should reject making an assigned instance a template", function () {
|
||||
instance.config.set("instance.assigned_host", 5);
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(instance.config, true, null),
|
||||
new lib.ConflictError("Instance must be unassigned before it can be made a template"),
|
||||
);
|
||||
});
|
||||
it("should reject unmarking a template that has inheritors", function () {
|
||||
assert.throws(
|
||||
() => instances.checkInheritance(template.config, false, null),
|
||||
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not assign a template to a host", async function () {
|
||||
controller.wsServer.hostConnections.set(5, { send: async () => {}, connector: { closing: false } });
|
||||
await assert.rejects(
|
||||
instances.assignInstance(10, 5),
|
||||
new lib.ValidationError("Templates cannot be assigned to a host"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not delete a template that has inheritors", async function () {
|
||||
await assert.rejects(
|
||||
instances.deleteInstance(10),
|
||||
new lib.ConflictError("Instance is the template of 1 other instance(s)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should report templates in instance details", function () {
|
||||
assert.equal(template.toInstanceDetails().isTemplate, true);
|
||||
assert.equal(child.toInstanceDetails().isTemplate, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -153,6 +153,17 @@ describe("controller/InstanceRecord", function () {
|
||||
assert.equal(details.startedAtMs, 100);
|
||||
assert.equal(details.updatedAtMs, 200);
|
||||
assert.equal(details.excludeFromStartAll, false);
|
||||
assert.equal(details.modPackId, undefined);
|
||||
});
|
||||
|
||||
it("should include the mod pack set in the config", function () {
|
||||
const config = new lib.InstanceConfig("controller", {
|
||||
"instance.id": 1,
|
||||
"instance.name": "TestInstance",
|
||||
"factorio.mod_pack_id": 3,
|
||||
});
|
||||
const details = new InstanceRecord(config, "stopped").toInstanceDetails();
|
||||
assert.equal(details.modPackId, 3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -135,6 +135,31 @@ describe("controller/UserManager", function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe(".verifyUserToken()", function () {
|
||||
const secret = () => Buffer.from(config.get("controller.auth_secret"), "base64");
|
||||
|
||||
it("should return the user the token was signed for", function () {
|
||||
const user = userManager.createUser("Judy");
|
||||
assert.equal(userManager.verifyUserToken(userManager.signUserToken(user)).id, user.id);
|
||||
});
|
||||
it("should reject a token for an unknown user", function () {
|
||||
const token = userManager.signUserToken({ id: "nobody" });
|
||||
assert.throws(() => userManager.verifyUserToken(token), new Error("invalid user"));
|
||||
});
|
||||
it("should reject a token issued before tokenValidAfter", function () {
|
||||
const user = userManager.createUser("Mallory");
|
||||
const token = jwt.sign({ aud: "user", user: user.id, iat: user.tokenValidAfter - 1 }, secret());
|
||||
assert.throws(() => userManager.verifyUserToken(token), new Error("invalid token"));
|
||||
});
|
||||
it("should reject host tokens and bad signatures", function () {
|
||||
const user = userManager.createUser("Niaj");
|
||||
const hostToken = jwt.sign({ aud: "host", host: 5 }, secret());
|
||||
assert.throws(() => userManager.verifyUserToken(hostToken), /audience invalid/);
|
||||
const forged = jwt.sign({ aud: "user", user: user.id }, "wrong secret");
|
||||
assert.throws(() => userManager.verifyUserToken(forged), /invalid signature/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".clearStatsOfInstance()", function () {
|
||||
it("should clear instance stats for all users", function () {
|
||||
const instanceId = 42;
|
||||
|
||||
+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.records.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.hosts.signHostToken(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.hosts.signHostToken(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.records.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.hosts.signHostToken(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.records.delete(record);
|
||||
wsServer.hostConnections.delete(5);
|
||||
},
|
||||
});
|
||||
await register(controller.hosts.signHostToken(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.hosts.signHostToken(5));
|
||||
assert.deepEqual(socket.closed, {
|
||||
code: lib.ConnectionClosed.RecoveryMode,
|
||||
reason: "Connection Refused: Controller in recovery mode",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+335
-5
@@ -1,8 +1,15 @@
|
||||
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 jwt from "jsonwebtoken";
|
||||
import express from "express";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { HostPairingManager, HostRecord } from "@clusterio/controller";
|
||||
|
||||
import { wait, expositionContentType } from "@clusterio/lib";
|
||||
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
|
||||
import * as mock from "../mock.js";
|
||||
|
||||
@@ -100,7 +107,7 @@ describe("controller/src/routes", function() {
|
||||
stream = await routes.createProxyStream(controller.app);
|
||||
responses = await Promise.all([
|
||||
(async function() {
|
||||
await wait(100);
|
||||
await lib.wait(100);
|
||||
return await fetch(`${endpoint}/${stream.id}`, {
|
||||
method: "PUT", body: "test content",
|
||||
});
|
||||
@@ -116,7 +123,7 @@ describe("controller/src/routes", function() {
|
||||
method: "PUT", body: "test content",
|
||||
}),
|
||||
(async function() {
|
||||
await wait(100);
|
||||
await lib.wait(100);
|
||||
return await fetch(`${endpoint}/${stream.id}`);
|
||||
}()),
|
||||
]);
|
||||
@@ -163,7 +170,7 @@ describe("controller/src/routes", function() {
|
||||
let response = await fetch(endpoint, { headers });
|
||||
assert.equal(response.status, 200);
|
||||
const contentType = response.headers.get("content-type");
|
||||
for (const part of expositionContentType.split("; ")) {
|
||||
for (const part of lib.expositionContentType.split("; ")) {
|
||||
assert(contentType.includes(part), `expected ${part} in content type ${contentType}`);
|
||||
}
|
||||
assert(/^# HELP /m.test(await response.text()), "expected exposition text");
|
||||
@@ -173,7 +180,7 @@ describe("controller/src/routes", function() {
|
||||
let calls = 0;
|
||||
controller.hooks.metrics.attach("test", async () => {
|
||||
calls += 1;
|
||||
await wait(100);
|
||||
await lib.wait(100);
|
||||
});
|
||||
const headers = { "X-Access-Token": testToken };
|
||||
let responses = await Promise.all([
|
||||
@@ -190,6 +197,42 @@ describe("controller/src/routes", function() {
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
});
|
||||
describe("missingExportAsset()", function() {
|
||||
let dir;
|
||||
beforeEach(async function() {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-static-"));
|
||||
await fs.writeFile(path.join(dir, "locale.abc.json"), "[]");
|
||||
const provider = new lib.MemoryDatastoreProvider();
|
||||
controller.modPacks = new lib.SubscribableDatastore(provider, new Map());
|
||||
const modPack = lib.ModPack.fromJSON({ id: 1, name: "test" });
|
||||
modPack.exportManifest = new lib.ExportManifest({
|
||||
locale: "locale.abc.json", metadata: "metadata.abc.json",
|
||||
});
|
||||
controller.modPacks.set(modPack);
|
||||
controller.app.use("/static", express.static(dir));
|
||||
controller.app.use("/static", routes.missingExportAsset);
|
||||
});
|
||||
afterEach(async function() {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
it("should serve export files that exist", async function() {
|
||||
const response = await fetch(`http://localhost:${port}/static/locale.abc.json`);
|
||||
assert.equal(response.status, 200);
|
||||
assert(controller.modPacks.get(1).exportManifest);
|
||||
});
|
||||
it("should drop the export and explain when a file is missing", async function() {
|
||||
const response = await fetch(`http://localhost:${port}/static/metadata.abc.json`);
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(await response.text(), /metadata\.abc\.json is missing/);
|
||||
assert.equal(controller.modPacks.get(1).exportManifest, undefined);
|
||||
});
|
||||
it("should pass through files not in an export", async function() {
|
||||
const response = await fetch(`http://localhost:${port}/static/other.js`);
|
||||
assert.equal(response.status, 404);
|
||||
assert.doesNotMatch(await response.text(), /export-data/);
|
||||
assert(controller.modPacks.get(1).exportManifest);
|
||||
});
|
||||
});
|
||||
describe("stripStaticPrefix()", function() {
|
||||
it("should remove a leading static/", function() {
|
||||
assert.equal(routes.stripStaticPrefix("static/main.abc.js"), "main.abc.js");
|
||||
@@ -262,6 +305,293 @@ 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.hostPairing = new HostPairingManager(controller);
|
||||
controller.wsServer.remoteAddr = req => req.socket.remoteAddress;
|
||||
adminToken = controller.users.signUserToken(controller.users.getByName("test"));
|
||||
playerToken = controller.users.signUserToken(controller.users.getByName("player"));
|
||||
});
|
||||
|
||||
function createHost(name) {
|
||||
const host = controller.hosts.createHost(name);
|
||||
return { hostId: host.id, token: controller.hosts.signHostToken(host.id) };
|
||||
}
|
||||
|
||||
function post(urlPath, body, headers = {}) {
|
||||
return fetch(`${endpoint}${urlPath}`, {
|
||||
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.records.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.records.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.records.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.records.set(new HostRecord(5, "five", "", new Map()));
|
||||
const hostToken = controller.hosts.signHostToken(5);
|
||||
controller.hosts.records.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, urlPath] of [
|
||||
["GET", `/poll?requestId=${id}`],
|
||||
["GET", `/poll/${id}`],
|
||||
["POST", `/poll/${id}`],
|
||||
["POST", `/${id}`],
|
||||
["GET", `/${id}`],
|
||||
["POST", `/cancel/${id}`],
|
||||
]) {
|
||||
const response = await fetch(`${endpoint}${urlPath}`, { method });
|
||||
assert.equal(response.status, 404, `${method} ${urlPath}`);
|
||||
}
|
||||
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 } = 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 } = 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() {
|
||||
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 } = createHost("Host A");
|
||||
await assertResponse(await check({ "Authorization": `Bearer ${token}` }), 200, { valid: true });
|
||||
controller.hosts.records.delete(controller.hosts.get(hostId));
|
||||
assert.equal((await check({ "Authorization": `Bearer ${token}` })).status, 401);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("/api/upload-save", function() {
|
||||
let endpoint, testToken;
|
||||
beforeEach(function() {
|
||||
|
||||
Reference in new issue
Block a user