Files
factorio-clusterio/test/integration/index.js
T
Danielv123 49a0b27d29 Address review of host pairing
Revoking a host now unassigns its instances instead of being refused
while it has any, and removes its system metrics.

Approving and revoking no longer force a save. Hosts are saved by
autosave and on exit like everything else, which also removes the
approving state from pairing requests.

The approval code and link are logged as warnings so they show up in
the systemd service, which only prints warnings and above. The pairing
limits are now the controller.host_pairing_timeout and
controller.host_pairing_max_pending config values, 0 removes the limit.

Subscribers take an optional permitted function, used for the pairing
requests instead of overrides in a subclass.
2026-10-31 16:31:45 +01:00

464 lines
15 KiB
JavaScript

/* eslint-disable no-console */
import path from "node:path";
import fs from "node:fs/promises";
import { readFileSync } from "node:fs";
import child_process from "node:child_process";
import jwt from "jsonwebtoken";
import util from "node:util";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { LineSplitter, logger } from "@clusterio/lib";
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
import { pairHost } from "@clusterio/host";
import "../setup_logging.js";
// Make sure permissions from plugins are loaded
import "../../plugins/global_chat/dist/node/index.js";
import "../../plugins/player_auth/dist/node/index.js";
import "../../plugins/research_sync/dist/node/index.js";
import "../../plugins/statistics_exporter/dist/node/index.js";
import "../../plugins/subspace_storage/dist/node/index.js";
export class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
super(connector);
this.hostUpdates = [];
this.hostPairingUpdates = [];
this.instanceUpdates = [];
this.saveUpdates = [];
this.modUpdates = [];
this.modPackUpdates = [];
this.userUpdates = [];
this.connector.on("connect", () => {
if (!subscribe) {
return;
}
this.send(
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.HostPairingUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host pairing subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceSaveDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting save subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.ModUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting mod subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.ModPackUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting mod pack subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.UserUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting user subscriptions:\n${err.stack}`));
});
this.handle(lib.AccountUpdateEvent);
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
this.handle(lib.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.bind(this));
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
this.handle(lib.ModPackUpdatesEvent, this.handleModPackUpdatesEvent.bind(this));
this.handle(lib.UserUpdatesEvent, this.handleUserUpdatesEvent.bind(this));
}
async handleHostUpdatesEvent(event) {
this.hostUpdates.push(...event.updates);
}
async handleHostPairingUpdatesEvent(event) {
this.hostPairingUpdates.push(...event.updates);
}
async handleInstanceDetailsUpdatesEvent(event) {
this.instanceUpdates.push(...event.updates);
}
async handleInstanceSaveDetailsUpdatesEvent(event) {
this.saveUpdates.push(event);
}
async handleModUpdatesEvent(event) {
this.modUpdates.push(...event.updates);
}
async handleModPackUpdatesEvent(event) {
this.modPackUpdates.push(...event.updates);
}
async handleUserUpdatesEvent(event) {
this.userUpdates.push(...event.updates);
}
async setLogSubscriptions(args) {
// Do nothing
}
}
export class TestControlConnector extends lib.WebSocketClientConnector {
register() {
this.sendHandshake(
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this.token, "test",
)
)
);
}
}
export class TestHostConnector extends lib.WebSocketClientConnector {
register() {
this.sendHandshake(
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.token, "test",
this.hostId, {},
)
)
);
}
}
// Mark that this test or suite of tests requires a factorio install to run.
export function requiresFactorio(testOrSuite) {
if (testOrSuite.skip) {
if (!haveFactorioInstall) {
testOrSuite.skip();
}
} else {
before(function() {
if (!haveFactorioInstall) {
this.skip();
}
});
}
}
export function hasFactorio() {
return haveFactorioInstall;
}
export function getFactorioVersion() {
return latestFactorioVersion;
}
export async function get(urlPath) {
const url = new URL("https://localhost:4443");
url.pathname = urlPath;
let res = await fetch(url);
if (res.status !== 200) {
const body = Buffer.from(await res.arrayBuffer());
throw new Error(`Got response code ${res.status}, content: ${body.toString()}`);
}
return res;
}
function loadJSON(filePath) {
try {
// eslint-disable-next-line node/no-sync
return JSON.parse(readFileSync(filePath, "utf8"));
} catch (err) {
if (err.code === "ENOENT") {
return undefined;
}
throw err;
}
}
function getFactorioDir(hostConfig) {
const factorioDir = hostConfig?.["host.factorio_directory"] ?? "factorio";
return path.join(factorioDir);
}
let controllerProcess;
let hostProcess;
let control;
let haveFactorioInstall;
let latestFactorioVersion;
const baseHostConfig = loadJSON("config-host.json");
export let url = "https://localhost:4443/";
// Signed in the root before hook, as the user has to exist before the token is issued
export let controlToken;
// Id of the test host, assigned by the controller when it is paired in the root before hook
export let hostId;
export let instancesDir = path.join("temp", "test", "instances");
export let modsDir = path.join("temp", "test", "mods");
export let databaseDir = path.join("temp", "test", "database");
export let factorioDir = getFactorioDir(baseHostConfig);
let pluginListPath = path.join("temp", "test", "plugin-list.json");
export let controllerConfigPath = path.join("temp", "test", "config-controller.json");
export let hostConfigPath = path.join("temp", "test", "config-host.json");
export let ctlConfigPath = path.join("temp", "test", "config-ctl.json");
function childOptions(options) {
return {
cwd: path.join("temp", "test"),
env: { ...process.env },
...options,
};
}
export function exec(command, options = {}) {
// Uncomment to show commands run in tests
// console.log(command);
return util.promisify(child_process.exec)(command, childOptions(options));
}
export function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller/dist/node/controller.js ${args[0]}`;
return exec(...args);
}
export function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host/dist/node/host.js ${args[0]}`;
return exec(...args);
}
export function execCtlProcess(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl/dist/node/ctl.js ${args[0]}`;
return exec(...args);
}
let inExecCtl = false;
export async function execCtl(command) {
inExecCtl = true;
process.chdir("temp/test");
try {
const initArgs = await initializeCtl(command, control.hooks, true);
if (!initArgs.shouldRun) {
return;
}
control.config = initArgs.ctlConfig;
const targetCommand = selectTargetCommand(initArgs.args, initArgs.rootCommands);
await targetCommand.run(initArgs.args, control);
} finally {
process.chdir("../..");
inExecCtl = false;
}
}
afterEach(function() {
if (inExecCtl) {
// This is needed in case a test times out and never runs the finally block
process.chdir("../..");
}
});
export async function sendRcon(instanceId, command) {
return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command));
}
export function getControl() {
return control;
}
/**
* Pair a new host with the test controller, approved right away with the admin token
*
* @param {string} name - Name to give the host.
* @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host.
*/
export async function pairTestHost(name) {
return await pairHost({ controllerUrl: url, name, token: controlToken });
}
/**
* Write a new host config for a paired host, with the fields the installer and pairing set
*
* @param {string} configPath - Path to write the config to.
* @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing.
* @param {Record<string, unknown>} fields - Other fields to set in the config.
*/
export async function writeHostConfig(configPath, pairing, fields = {}) {
const hostConfig = new lib.HostConfig("host");
hostConfig.set("host.id", pairing.hostId);
hostConfig.set("host.name", pairing.name);
hostConfig.set("host.controller_url", url);
hostConfig.set("host.controller_token", pairing.token);
for (const [name, value] of Object.entries(fields)) {
hostConfig.set(name, value);
}
await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 });
}
/** @returns {Promise<child_process.ChildProcess>} */
export function spawn(name, cmd, waitFor, options = {}) {
const silent = process.env.SILENT_TEST;
const bootstrap = !controllerProcess || !hostProcess;
function log(...args) {
if (!silent || bootstrap) {
console.log(...args);
}
}
return new Promise((resolve, reject) => {
log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onData = line => {
line = line.toString("utf8");
if (waitFor.test(line)) {
if (silent) {
stdout.off("data", onData);
stderr.off("data", onData);
}
resolve(process);
}
log(name, line);
};
stdout.on("data", onData);
stderr.on("data", onData);
process.stdout.pipe(stdout);
process.stderr.pipe(stderr);
});
}
export function spawnNode(name, cmd, waitFor, options) {
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
}
before(async function() {
this.timeout(40000);
const silent = process.env.SILENT_TEST;
if (silent) {
console.log("SILENT_TEST is present in env, loggers after bootstrap will be muted.");
}
// If fast test is enabled then output that it is
if (process.env.FAST_TEST) {
console.log("FAST_TEST is present in env, slow tests will be skipped.");
}
if (process.env.NO_EXTERNAL_TEST) {
console.log("NO_EXTERNAL_TEST is present in env, tests depending on external APIs will be skipped.");
}
await fs.rm(instancesDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(modsDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(databaseDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(pluginListPath, { force: true });
await fs.rm(controllerConfigPath, { force: true });
await fs.rm(hostConfigPath, { force: true });
await fs.rm(ctlConfigPath, { force: true });
await fs.mkdir(path.join("temp", "test"), { recursive: true });
console.log("Building Mods");
await fs.mkdir(modsDir, { recursive: true });
await lib.build({
build: true,
pack: true,
sourceDir: "packages/host/lua/clusterio_lib",
outputDir: modsDir,
});
console.log("Setting Controller Config");
await execController("config create");
await execController("config set controller.auth_secret TestSecretDoNotUse");
await execController("config set controller.http_port 8880");
await execController("config set controller.https_port 4443");
await execController("config set controller.heartbeat_interval 0.25");
await execController("config set controller.session_timeout 2");
await execController("config set controller.tls_certificate ../../test/file/tls/cert.pem");
await execController("config set controller.tls_private_key ../../test/file/tls/key.pem");
console.log("Setting Controller Plugins");
await execCtlProcess("plugin add ../../test/file/test_plugin");
await execCtlProcess("plugin add ../../plugins/global_chat");
await execCtlProcess("plugin add ../../plugins/research_sync");
await execCtlProcess("plugin add ../../plugins/statistics_exporter");
await execCtlProcess("plugin add ../../plugins/subspace_storage");
await execCtlProcess("plugin add ../../plugins/player_sync");
await execCtlProcess("plugin add ../../plugins/player_auth");
console.log("Bootstrapping");
await execController("bootstrap create-admin test");
await execController("bootstrap create-ctl-config test");
controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
latestFactorioVersion = [...factorioVersions.values()]
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
haveFactorioInstall = factorioVersions.size > 0;
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
const pairing = await pairTestHost("host");
hostId = pairing.hostId;
await writeHostConfig(hostConfigPath, pairing);
await execHost("config create");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
hostProcess = await spawnNode("host:", "../../packages/host run", /Started host/);
const controlConnector = new TestControlConnector(url, 2);
controlConnector.token = controlToken;
control = new TestControl(controlConnector);
await controlConnector.connect();
const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} config create`, undefined, true);
control.config = initArgs.ctlConfig;
control.hooks = initArgs.ctlHooks;
const testPack = lib.ModPack.fromJSON({});
testPack.id = 12;
testPack.name = "subspace_storage-pack";
testPack.factorioVersion = latestFactorioVersion;
const [major, minor] = lib.normaliseMajorMinorVersion(latestFactorioVersion).split(".");
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: `2.1.${major}${minor}` });
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
await control.sendTo(
"controller",
new lib.ControllerConfigSetFieldRequest("controller.default_mod_pack_id", "12"),
);
});
after(async function() {
this.timeout(20000);
if (hostProcess) {
console.log("Shutting down host");
hostProcess.kill("SIGINT");
await events.once(hostProcess, "exit");
}
if (controllerProcess) {
console.log("Shutting down controller");
controllerProcess.kill("SIGINT");
await events.once(controllerProcess, "exit");
}
if (control) {
await control.connector.close();
}
if (haveFactorioInstall === false) {
console.warn("Many tests may have been skipped due to no factorio install being detected");
}
});
// Ensure the test processes are stopped.
process.on("exit", () => {
if (hostProcess) { hostProcess.kill(); }
if (controllerProcess) { controllerProcess.kill(); }
});