mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
520 lines
19 KiB
JavaScript
520 lines
19 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import * as lib from "@clusterio/lib";
|
|
|
|
import { Controller, ControlConnection } from "@clusterio/controller";
|
|
import { testMatrix, testRoundTripJsonSerialisable } from "../common.js";
|
|
import "../setup_logging.js";
|
|
|
|
describe("messages/controller", function() {
|
|
/** @type {Controller} */
|
|
let controller;
|
|
/** @type {ControlConnection} */
|
|
let controlConnection;
|
|
/** @type {lib.InstanceConfig} */
|
|
let instanceConfig;
|
|
|
|
beforeEach(function() {
|
|
const controllerConfig = new lib.ControllerConfig("controller");
|
|
const connection = new lib.VirtualConnector(
|
|
lib.Address.fromShorthand("controller"),
|
|
lib.Address.fromShorthand({ controlId: 1 }),
|
|
);
|
|
controller = new Controller(lib.logger, [], controllerConfig);
|
|
instanceConfig = new lib.InstanceConfig("controller");
|
|
controller.instances.createInstance(instanceConfig);
|
|
const user = controller.users.getOrCreateUser("test");
|
|
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
|
|
});
|
|
|
|
describe("ControllerMarkRestartRequiredRequest", function() {
|
|
it("runs", async function() {
|
|
await controlConnection.handleControllerMarkRestartRequiredRequest(
|
|
new lib.ControllerMarkRestartRequiredRequest()
|
|
);
|
|
assert.equal(controller.config.restartRequired, true);
|
|
});
|
|
});
|
|
|
|
describe("ControllerUpdateRequest", function() {
|
|
it("runs", async function() {
|
|
controller.config.set("controller.allow_remote_updates", true);
|
|
await controlConnection.handleControllerUpdateRequest(new lib.ControllerUpdateRequest());
|
|
});
|
|
it("rejects if updates are disabled", async function() {
|
|
controller.config.set("controller.allow_remote_updates", false);
|
|
await assert.rejects(
|
|
controlConnection.handleControllerUpdateRequest(new lib.ControllerUpdateRequest()),
|
|
/Remote updates are disabled on this machine/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("RoleDeleteRequest", function() {
|
|
it("removes the role from users and sends a user update", async function() {
|
|
const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", []));
|
|
const user = controller.users.getOrCreateUser("player");
|
|
user.addRole(id);
|
|
const updated = [];
|
|
controller.users.records.on("update", users => updated.push(...users));
|
|
await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id));
|
|
assert.equal(controller.roles.has(id), false);
|
|
assert.equal(user.roleIds.has(id), false);
|
|
assert(updated.some(u => u.name === "player" && !u.roleIds.has(id)), "no user update sent");
|
|
});
|
|
});
|
|
|
|
describe("ControllerConfigGetRequest", function() {
|
|
it("runs", async function() {
|
|
const config = await controlConnection.handleControllerConfigGetRequest(
|
|
new lib.ControllerConfigGetRequest()
|
|
);
|
|
assert.deepEqual(config, controller.config.toRemote("control"));
|
|
});
|
|
});
|
|
|
|
describe("ControllerConfigSetFieldRequest", function() {
|
|
it("is round trip json serialisable", function() {
|
|
testRoundTripJsonSerialisable(lib.ControllerConfigSetFieldRequest, testMatrix(
|
|
["controller.name", "invalid"], // field
|
|
["Foo Bar", "true", "5", "{}"], // value
|
|
));
|
|
});
|
|
it("runs", async function() {
|
|
await controlConnection.handleControllerConfigSetFieldRequest(
|
|
new lib.ControllerConfigSetFieldRequest("controller.name", "Foo Bar")
|
|
);
|
|
assert.equal(controller.config.get("controller.name"), "Foo Bar");
|
|
});
|
|
it("rejects if the field does not exist", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetFieldRequest(
|
|
new lib.ControllerConfigSetFieldRequest("invalid", "Foo Bar")
|
|
),
|
|
/No field named 'invalid'/
|
|
);
|
|
});
|
|
it("rejects if the field is inaccessible", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetFieldRequest(
|
|
new lib.ControllerConfigSetFieldRequest("controller.version", "2.0.0")
|
|
),
|
|
/Field 'controller.version' is not accessible from control/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ControllerConfigSetPropRequest", function() {
|
|
it("is round trip json serialisable", function() {
|
|
testRoundTripJsonSerialisable(lib.ControllerConfigSetPropRequest, testMatrix(
|
|
["controller.name", "invalid"], // field
|
|
["foo", "bar"], // prop
|
|
["Foo Bar", true, 5, {}], // value
|
|
));
|
|
});
|
|
it("runs", async function() {
|
|
this.skip(); // No object fields on controller
|
|
});
|
|
it("rejects if the field does not exist", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetPropRequest(
|
|
new lib.ControllerConfigSetPropRequest("invalid", "foo", "bar")
|
|
),
|
|
/No field named 'invalid'/
|
|
);
|
|
});
|
|
it("rejects if the field is not an object", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetPropRequest(
|
|
new lib.ControllerConfigSetPropRequest("controller.name", "foo", "bar")
|
|
),
|
|
/Cannot set property on non-object field 'controller\.name'/
|
|
);
|
|
});
|
|
it("rejects if the field is inaccessible", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetPropRequest(
|
|
new lib.ControllerConfigSetPropRequest("controller.version", "foo", "bar")
|
|
),
|
|
/Field 'controller\.version' is not accessible from control/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ControllerConfigSetRequest", function() {
|
|
it("is round trip json serialisable", function() {
|
|
testRoundTripJsonSerialisable(lib.ControllerConfigSetRequest, testMatrix(
|
|
[ // fields
|
|
{ "foo": "bar", "baz": { "cat": "dog" } },
|
|
{ "foo": "5", "baz": { "cat": "5" } },
|
|
{ "foo": "true", "baz": { "cat": "true" } },
|
|
{ "foo": "{}", "baz": { "cat": "{}" } },
|
|
],
|
|
));
|
|
});
|
|
it("runs", async function() {
|
|
await controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
|
"controller.name": "Foo Bar",
|
|
"controller.mods_directory": "Bar Baz",
|
|
}));
|
|
assert.equal(controller.config.get("controller.name"), "Foo Bar");
|
|
assert.equal(controller.config.get("controller.mods_directory"), "Bar Baz");
|
|
});
|
|
it("rejects if the field does not exist", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
|
"controller.name": "Foo Bar",
|
|
"invalid": "Bar Baz",
|
|
})),
|
|
/No field named 'invalid'/
|
|
);
|
|
assert.equal(controller.config.get("controller.name"), "Your Cluster");
|
|
});
|
|
it("rejects if the field is not an object", async function() {
|
|
const prevDir = controller.config.get("controller.mods_directory");
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
|
"controller.name": "Foo Bar",
|
|
"controller.mods_directory": {
|
|
"foo": "bar",
|
|
},
|
|
})),
|
|
/Cannot set property on non-object field 'controller\.mods_directory'/
|
|
);
|
|
assert.equal(controller.config.get("controller.name"), "Your Cluster");
|
|
assert.equal(controller.config.get("controller.mods_directory"), prevDir);
|
|
});
|
|
it("rejects if the field is inaccessible", async function() {
|
|
const prevVersion = controller.config.get("controller.version");
|
|
await assert.rejects(
|
|
controlConnection.handleControllerConfigSetRequest(new lib.ControllerConfigSetRequest({
|
|
"controller.name": "Foo Bar",
|
|
"controller.version": "2.0.0",
|
|
})),
|
|
/Field 'controller\.version' is not accessible from control/
|
|
);
|
|
assert.equal(controller.config.get("controller.name"), "Your Cluster");
|
|
assert.equal(controller.config.get("controller.version"), prevVersion);
|
|
});
|
|
});
|
|
|
|
describe("Host pairing", function() {
|
|
const details = [
|
|
new lib.HostPairingDetails("ABC-DEF", "Host A", "10.0.0.1", "2.0.0", 100, 200),
|
|
new lib.HostPairingDetails("GHJ-KMN", "Host B", "2001:db8::1", "", 0, 300, true),
|
|
];
|
|
|
|
it("round trips HostPairingListRequest responses", function() {
|
|
const validate = lib.compile(lib.HostPairingListRequest.Response.jsonSchema);
|
|
const json = JSON.parse(JSON.stringify(details));
|
|
assert(validate(json), JSON.stringify(validate.errors));
|
|
assert.deepEqual(lib.HostPairingListRequest.Response.fromJSON(json), details);
|
|
});
|
|
it("round trips HostPairingUpdatesEvent", function() {
|
|
testRoundTripJsonSerialisable(lib.HostPairingUpdatesEvent, [[[]], [details]]);
|
|
});
|
|
it("round trips HostPairingApproveRequest", function() {
|
|
testRoundTripJsonSerialisable(lib.HostPairingApproveRequest, testMatrix(
|
|
["ABC-DEF", "abcdef"], // code
|
|
[undefined, "Renamed"], // name
|
|
));
|
|
const validate = lib.compile(lib.HostPairingApproveRequest.Response.jsonSchema);
|
|
assert(validate(1234));
|
|
});
|
|
it("round trips HostPairingRejectRequest", function() {
|
|
testRoundTripJsonSerialisable(lib.HostPairingRejectRequest, [["ABC-DEF"], ["abcdef"]]);
|
|
});
|
|
});
|
|
|
|
describe("InstanceConfigGetRequest", function() {
|
|
it("runs", async function() {
|
|
const config = await controlConnection.handleInstanceConfigGetRequest(
|
|
new lib.InstanceConfigGetRequest(instanceConfig.get("instance.id"))
|
|
);
|
|
assert.deepEqual(config, instanceConfig.toRemote("control"));
|
|
});
|
|
});
|
|
|
|
describe("InstanceConfigSetFieldRequest", function() {
|
|
it("is round trip json serialisable", function() {
|
|
testRoundTripJsonSerialisable(lib.InstanceConfigSetFieldRequest, testMatrix(
|
|
[1], // instance id
|
|
["controller.name", "invalid"], // field
|
|
["Foo Bar", "true", "5", "{}"], // value
|
|
));
|
|
});
|
|
it("runs", async function() {
|
|
await controlConnection.handleInstanceConfigSetFieldRequest(
|
|
new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "instance.name", "Foo Bar")
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "Foo Bar");
|
|
});
|
|
it("rejects if the field does not exist", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetFieldRequest(
|
|
new lib.InstanceConfigSetFieldRequest(instanceConfig.get("instance.id"), "invalid", "Foo Bar")
|
|
),
|
|
/No field named 'invalid'/
|
|
);
|
|
});
|
|
it("rejects if the field is inaccessible", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetFieldRequest(
|
|
new lib.InstanceConfigSetFieldRequest(
|
|
instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "3000"
|
|
)
|
|
),
|
|
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
|
|
);
|
|
});
|
|
it("rejects setting 'instance.assigned_host'", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetFieldRequest(
|
|
new lib.InstanceConfigSetFieldRequest(
|
|
instanceConfig.get("instance.id"), "instance.assigned_host", 5
|
|
)
|
|
),
|
|
/instance\.assigned_host must be set through the assign-host interface/
|
|
);
|
|
});
|
|
it("rejects setting 'instance.id'", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetFieldRequest(
|
|
new lib.InstanceConfigSetFieldRequest(
|
|
instanceConfig.get("instance.id"), "instance.id", 5
|
|
)
|
|
),
|
|
/Setting instance\.id is not supported/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("InstanceConfigSetPropRequest", function() {
|
|
it("is round trip json serialisable", function() {
|
|
testRoundTripJsonSerialisable(lib.InstanceConfigSetPropRequest, testMatrix(
|
|
[1], // instance id
|
|
["controller.name", "invalid"], // field
|
|
["foo", "bar"], // prop
|
|
["Foo Bar", true, 5, {}], // value
|
|
));
|
|
});
|
|
it("runs", async function() {
|
|
controlConnection.handleInstanceConfigSetPropRequest(
|
|
new lib.InstanceConfigSetPropRequest(
|
|
instanceConfig.get("instance.id"), "factorio.settings", "name", "Foo Bar"
|
|
)
|
|
);
|
|
const { name, ...rest } = instanceConfig.get("factorio.settings");
|
|
assert.equal(name, "Foo Bar");
|
|
});
|
|
it("rejects if the field does not exist", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetPropRequest(
|
|
new lib.InstanceConfigSetPropRequest(instanceConfig.get("instance.id"), "invalid", "foo", "bar")
|
|
),
|
|
/No field named 'invalid'/
|
|
);
|
|
});
|
|
it("rejects if the field is not an object", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetPropRequest(
|
|
new lib.InstanceConfigSetPropRequest(
|
|
instanceConfig.get("instance.id"), "instance.name", "foo", "bar"
|
|
)
|
|
),
|
|
/Cannot set property on non-object field 'instance\.name'/
|
|
);
|
|
});
|
|
it("rejects if the field is inaccessible", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetPropRequest(
|
|
new lib.InstanceConfigSetPropRequest(
|
|
instanceConfig.get("instance.id"), "factorio.host_assigned_game_port", "foo", "bar"
|
|
)
|
|
),
|
|
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("InstanceConfigSetRequest", function() {
|
|
it("is round trip json serialisable", function() {
|
|
testRoundTripJsonSerialisable(lib.InstanceConfigSetRequest, testMatrix(
|
|
[1], // instance id
|
|
[ // fields
|
|
{ "foo": "bar", "baz": { "cat": "dog" } },
|
|
{ "foo": "5", "baz": { "cat": "5" } },
|
|
{ "foo": "true", "baz": { "cat": "true" } },
|
|
{ "foo": "{}", "baz": { "cat": "{}" } },
|
|
],
|
|
));
|
|
});
|
|
it("runs", async function() {
|
|
await controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
|
instanceConfig.get("instance.id"), {
|
|
"instance.name": "Foo Bar",
|
|
"factorio.executable_path": "Bar Baz",
|
|
})
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "Foo Bar");
|
|
assert.equal(instanceConfig.get("factorio.executable_path"), "Bar Baz");
|
|
});
|
|
it("rejects if the field does not exist", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
|
instanceConfig.get("instance.id"), {
|
|
"instance.name": "Foo Bar",
|
|
"invalid": "Bar Baz",
|
|
})
|
|
),
|
|
/No field named 'invalid'/
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
|
});
|
|
it("rejects if the field is not an object", async function() {
|
|
const prevPath = instanceConfig.get("factorio.executable_path");
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
|
instanceConfig.get("instance.id"), {
|
|
"instance.name": "Foo Bar",
|
|
"factorio.executable_path": {
|
|
"foo": "bar",
|
|
},
|
|
})
|
|
),
|
|
/Cannot set property on non-object field 'factorio\.executable_path'/
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
|
assert.equal(instanceConfig.get("factorio.executable_path"), prevPath);
|
|
});
|
|
it("rejects if the field is inaccessible", async function() {
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
|
instanceConfig.get("instance.id"), {
|
|
"instance.name": "Foo Bar",
|
|
"factorio.host_assigned_game_port": "Bar Baz",
|
|
})
|
|
),
|
|
/Field 'factorio\.host_assigned_game_port' is not accessible from control/
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
|
// Can't check value of host_assigned_game_port as it is not accessible on controller
|
|
});
|
|
it("rejects setting 'instance.assigned_host'", async function() {
|
|
const prevHost = instanceConfig.get("instance.assigned_host");
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
|
instanceConfig.get("instance.id"), {
|
|
"instance.name": "Foo Bar",
|
|
"instance.assigned_host": 5,
|
|
})
|
|
),
|
|
/instance\.assigned_host must be set through the assign-host interface/
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
|
assert.equal(instanceConfig.get("instance.assigned_host"), prevHost);
|
|
});
|
|
it("rejects setting 'instance.id'", async function() {
|
|
const prevId = instanceConfig.get("instance.id");
|
|
await assert.rejects(
|
|
controlConnection.handleInstanceConfigSetRequest(new lib.InstanceConfigSetRequest(
|
|
instanceConfig.get("instance.id"), {
|
|
"instance.name": "Foo Bar",
|
|
"instance.id": 5,
|
|
})
|
|
),
|
|
/Setting instance\.id is not supported/
|
|
);
|
|
assert.equal(instanceConfig.get("instance.name"), "New Instance");
|
|
assert.equal(instanceConfig.get("instance.id"), prevId);
|
|
});
|
|
});
|
|
|
|
describe("External", function() {
|
|
it("runs", async function() {
|
|
let callCount = 0;
|
|
const versions = [
|
|
{ version: "1.2.3", stable: true, headlessUrl: "path/to/download" },
|
|
{ version: "1.2.4", stable: false, headlessUrl: "path/to/download" },
|
|
];
|
|
|
|
// Mock the cache to avoid needless fetches
|
|
controller.factorioVersions = new lib.ValueCache(async () => {
|
|
callCount += 1;
|
|
return versions;
|
|
});
|
|
|
|
// First call should result in a fetch
|
|
const versions1 = await controlConnection.handleFactorioVersionsRequest(
|
|
new lib.FactorioVersionsRequest()
|
|
);
|
|
|
|
assert.deepEqual(versions1, versions);
|
|
assert.equal(callCount, 1);
|
|
|
|
// Second call should not fetch due to default maxAgeMs being greater than 0
|
|
const versions2 = await controlConnection.handleFactorioVersionsRequest(
|
|
new lib.FactorioVersionsRequest()
|
|
);
|
|
|
|
assert.deepEqual(versions2, versions);
|
|
assert.equal(callCount, 1);
|
|
|
|
// Setting maxAgeMs to 0 should result in a new fetch
|
|
const versions3 = await controlConnection.handleFactorioVersionsRequest(
|
|
new lib.FactorioVersionsRequest(0)
|
|
);
|
|
|
|
assert.deepEqual(versions3, versions);
|
|
assert.equal(callCount, 2);
|
|
});
|
|
it("round trips LatestReleasesRequest", function() {
|
|
testRoundTripJsonSerialisable(lib.LatestReleasesRequest, [[], [0], [60000]]);
|
|
});
|
|
it("caches the latest releases and persists them for offline use", async function() {
|
|
const releases = {
|
|
stable: { headless: "2.0.77" },
|
|
experimental: { headless: "2.1.8" },
|
|
};
|
|
const databaseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-test-"));
|
|
controller.config.set("controller.database_directory", databaseDirectory);
|
|
const cachePath = path.join(databaseDirectory, "latest-releases.json");
|
|
const originalFetch = global.fetch;
|
|
try {
|
|
let callCount = 0;
|
|
global.fetch = async () => {
|
|
callCount += 1;
|
|
return { ok: true, status: 200, statusText: "OK", json: async () => releases };
|
|
};
|
|
|
|
// First call fetches from the api and persists the result to disk
|
|
const fetched = await controlConnection.handleLatestReleasesRequest(
|
|
new lib.LatestReleasesRequest(0)
|
|
);
|
|
assert.deepEqual(fetched, releases);
|
|
assert.equal(callCount, 1);
|
|
assert.deepEqual(JSON.parse(await fs.readFile(cachePath, "utf8")), releases);
|
|
|
|
// When the api is unavailable the persisted copy is served instead
|
|
global.fetch = async () => { throw new Error("Network Error"); };
|
|
const cached = await controlConnection.handleLatestReleasesRequest(
|
|
new lib.LatestReleasesRequest(0)
|
|
);
|
|
assert.deepEqual(cached, releases);
|
|
|
|
// With no persisted copy and an unavailable api an empty object is returned
|
|
await fs.rm(cachePath);
|
|
const empty = await controlConnection.handleLatestReleasesRequest(
|
|
new lib.LatestReleasesRequest(0)
|
|
);
|
|
assert.deepEqual(empty, {});
|
|
} finally {
|
|
global.fetch = originalFetch;
|
|
await fs.rm(databaseDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
});
|