mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
2595 lines
103 KiB
JavaScript
2595 lines
103 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs/promises";
|
|
import jwt from "jsonwebtoken";
|
|
import path from "node:path";
|
|
import events from "node:events";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { wait } from "@clusterio/lib";
|
|
import * as libBuildMod from "@clusterio/lib/build_mod.js";
|
|
|
|
import * as testStrings from "../lib/factorio/test_strings.js";
|
|
import {
|
|
TestControl, TestControlConnector, TestHostConnector, url, controlToken, hostId,
|
|
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
|
|
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
|
|
requiresFactorio, hasFactorio, pairTestHost, writeHostConfig,
|
|
} from "./index.js";
|
|
import { pairHost, checkHostToken, PairingError } from "@clusterio/host";
|
|
import { externalTest, slowTest } from "../common.js";
|
|
import { getTestRconPort } from "./rcon_port.js";
|
|
import { ControllerEcho, HostEchoReceived, InstanceEcho } from "../file/test_plugin/messages.js";
|
|
|
|
|
|
/** @returns {Promise<Map<number, lib.InstanceDetails>>} */
|
|
async function getInstances() {
|
|
let instances = await getControl().send(new lib.InstanceDetailsListRequest());
|
|
return new Map(instances.map(instance => [instance.id, instance]));
|
|
}
|
|
|
|
async function checkInstanceStatus(id, status) {
|
|
let instances = await getInstances();
|
|
assert.equal(instances.get(44).status, status, "incorrect instance status");
|
|
}
|
|
|
|
// Factorio directory relative to temp/test where the hosts are run from
|
|
const hostFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
|
|
|
|
/** Id, token and name of the alt-host, paired the first time it starts */
|
|
const altHost = { hostId: undefined, token: undefined, name: undefined };
|
|
|
|
async function pairAltHost() {
|
|
if (altHost.hostId === undefined) {
|
|
Object.assign(altHost, await pairTestHost("alt-host"));
|
|
}
|
|
}
|
|
|
|
async function spawnAltHost(config = "alt-host-config.json") {
|
|
return await spawnNode("alt-host:", `../../packages/host run --config ${config}`, /Started host/);
|
|
}
|
|
|
|
async function startAltHost() {
|
|
let config = "alt-host-config.json";
|
|
let configPath = path.join("temp", "test", config);
|
|
await fs.rm(configPath, { force: true });
|
|
await fs.rm(path.join("temp", "test", "alt-instances"), { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.rm(path.join("temp", "test", "alt-mods"), { force: true, recursive: true, maxRetries: 10 });
|
|
await pairAltHost();
|
|
await writeHostConfig(configPath, altHost);
|
|
await execHost(`--config ${config} config set host.instances_directory alt-instances`);
|
|
await execHost(`--config ${config} config set host.factorio_directory ${hostFactorioDir}`);
|
|
await execHost(`--config ${config} config set host.mods_directory alt-mods`);
|
|
return await spawnAltHost(config);
|
|
}
|
|
|
|
async function stopAltHost(hostProcess) {
|
|
if (hostProcess && hostProcess.exitCode === null) {
|
|
const waitForExit = lib.timeout(
|
|
events.once(hostProcess, "exit"),
|
|
10e3,
|
|
"timeout"
|
|
).catch(() => {});
|
|
try {
|
|
await execCtl(`host stop ${altHost.hostId}`);
|
|
} catch (err) {
|
|
// Some tests cause the host to stop
|
|
}
|
|
if (await waitForExit === "timeout" && hostProcess.exitCode === null) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("Stopping alt-host failed. Killing.");
|
|
hostProcess.kill("SIGINT");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runWithAltHost(callback) {
|
|
let hostProcess;
|
|
try {
|
|
hostProcess = await startAltHost();
|
|
return await callback();
|
|
} finally {
|
|
await stopAltHost(hostProcess);
|
|
}
|
|
}
|
|
|
|
async function uploadSave(instanceId, name, content) {
|
|
const uploadUrl = `https://localhost:4443/api/upload-save?instance_id=${instanceId}&filename=${name}`;
|
|
return await fetch(uploadUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"X-Access-Token": controlToken,
|
|
"Content-Type": "application/zip",
|
|
"Content-Length": String(content.length),
|
|
},
|
|
body: content,
|
|
});
|
|
}
|
|
|
|
async function deleteSave(instanceId, save) {
|
|
await getControl().send(new lib.InstanceDeleteSaveRequest(instanceId, save));
|
|
}
|
|
|
|
async function getUsers() {
|
|
let users = await getControl().send(new lib.UserListRequest());
|
|
return new Map(users.map(user => [user.id, user]));
|
|
}
|
|
|
|
function jsonArg(value) {
|
|
return `"${JSON.stringify(value).replace(/"/g, process.platform === "win32" ? '""' : '\\"')}"`;
|
|
}
|
|
|
|
/** Run a ctl command in this process and return what it printed */
|
|
async function execCtlOutput(command) {
|
|
const lines = [];
|
|
const originalLog = console.log; // eslint-disable-line no-console
|
|
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
|
try {
|
|
await execCtl(command);
|
|
} finally {
|
|
console.log = originalLog; // eslint-disable-line no-console
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/** Validator for assert.rejects expecting a PairingError with the given HTTP status */
|
|
function pairingError(status) {
|
|
return err => {
|
|
assert(err instanceof PairingError, `Expected a PairingError, got ${err.stack}`);
|
|
assert.equal(err.status, status, err.message);
|
|
return true;
|
|
};
|
|
}
|
|
|
|
describe("Integration of Clusterio", function() {
|
|
describe("clusteriocontroller", function() {
|
|
describe("bootstrap generate-user-token", function() {
|
|
it("work for existing user", async function() {
|
|
slowTest(this);
|
|
await execController("bootstrap generate-user-token test");
|
|
});
|
|
|
|
it("fails if user does not exist", async function() {
|
|
slowTest(this);
|
|
await assert.rejects(
|
|
execController("bootstrap generate-user-token invalid")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("bootstrap create-admin", function() {
|
|
it("should refuse to modify the database files when locked", async function() {
|
|
await assert.rejects(
|
|
execController("bootstrap create-admin BootstrapAdminTest")
|
|
);
|
|
});
|
|
it("should modify the database files when locked if bypass option is given", async function() {
|
|
const lockFilePath = `${controllerConfigPath}.lock`;
|
|
await fs.copyFile(lockFilePath, "temp.lock");
|
|
try {
|
|
await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file");
|
|
} finally {
|
|
await fs.rename("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
|
|
}
|
|
|
|
const json = JSON.parse(await fs.readFile(path.join(databaseDir, "users.json"), "utf8"));
|
|
assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true);
|
|
});
|
|
});
|
|
|
|
describe("copy-static", function() {
|
|
it("should copy the files from dist/web/static", async function() {
|
|
slowTest(this);
|
|
const dir = "temp/test/copy-static";
|
|
await fs.rm(dir, { force: true, recursive: true, maxRetries: 10 });
|
|
await execController("copy-static copy-static");
|
|
const controllerFiles = await fs.readdir("packages/controller/dist/web/static");
|
|
const globalChatFiles = await fs.readdir("plugins/global_chat/dist/web/static");
|
|
const targetFiles = await fs.readdir(dir);
|
|
assert(
|
|
controllerFiles.every(name => targetFiles.includes(name)),
|
|
"Missing files from controller static folder",
|
|
);
|
|
assert(
|
|
globalChatFiles.every(name => targetFiles.includes(name)),
|
|
"Missing files from global_chat's static folder",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("config", function() {
|
|
it("can read the config file", async function() {
|
|
await execController("config list");
|
|
});
|
|
it("should refuse to modify the config file while locked", async function() {
|
|
await assert.rejects(
|
|
execController("config set controller.name ConfigEditTest")
|
|
);
|
|
});
|
|
it("should modify the config file while locked if bypass option is given", async function() {
|
|
const lockFilePath = `${controllerConfigPath}.lock`;
|
|
await fs.copyFile(lockFilePath, "temp.lock");
|
|
try {
|
|
await execController("config set controller.name ConfigEditTest2 --bypass-lock-file");
|
|
} finally {
|
|
await fs.rename("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
|
|
}
|
|
|
|
const json = JSON.parse(await fs.readFile(path.join(controllerConfigPath), "utf8"));
|
|
assert.equal(json["controller.name"], "ConfigEditTest2");
|
|
});
|
|
});
|
|
|
|
describe("run", function() {
|
|
it("should handle resume of an active connection", async function() {
|
|
slowTest(this);
|
|
let connectorA = new TestControlConnector(url, 2);
|
|
connectorA.token = controlToken;
|
|
let controlA = new TestControl(connectorA);
|
|
await connectorA.connect();
|
|
connectorA._closing = true;
|
|
connectorA.stopHeartbeat();
|
|
connectorA.on("error", () => {});
|
|
|
|
let connectorB = new TestControlConnector(url, 2);
|
|
connectorB.token = controlToken;
|
|
connectorB.src = connectorA.src;
|
|
let controlB = new TestControl(connectorB);
|
|
connectorB._sessionToken = connectorA._sessionToken;
|
|
connectorB._sessionTimeout = connectorA._sessionTimeout;
|
|
connectorB._startedResumingMs = Date.now();
|
|
connectorB._state = "resuming";
|
|
connectorB._doConnect();
|
|
await events.once(connectorB, "resume");
|
|
await connectorB.close(1000, "");
|
|
});
|
|
it("should refuse to start a second process while locked", async function() {
|
|
await assert.rejects(execController("run"));
|
|
});
|
|
it("should start a second process while locked if bypass option is given", async function() {
|
|
slowTest(this);
|
|
const lockFilePath = `${controllerConfigPath}.lock`;
|
|
await fs.copyFile(lockFilePath, "temp.lock");
|
|
try {
|
|
await execController("run --create-config --bypass-lock-file");
|
|
} catch (err) {
|
|
assert.equal(/Error: Server listening failed/.test(err.stderr), true);
|
|
}
|
|
await fs.rename("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
|
|
});
|
|
it("should refuse to start if the config is missing and --create-config is not passed", async function() {
|
|
const dir = path.join("temp", "test", "empty_controller");
|
|
await fs.rm(dir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await assert.rejects(execController("run", { cwd: dir }));
|
|
});
|
|
it("should refuse to start when no users are loaded", async function() {
|
|
const dir = path.join("temp", "test", "empty_controller");
|
|
await fs.rm(dir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await assert.rejects(execController("run --create-config", { cwd: dir }));
|
|
});
|
|
it("should start when no users are loaded and bypass options are given", async function() {
|
|
slowTest(this);
|
|
const dir = path.join("temp", "test", "empty_controller");
|
|
await fs.rm(dir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const child = await spawnNode(
|
|
"altController",
|
|
"../../../packages/controller run --create-config --no-check-user-count",
|
|
/Started controller/,
|
|
{ cwd: dir },
|
|
);
|
|
assert.ok(child.kill());
|
|
assert.equal(child.killed, true);
|
|
});
|
|
});
|
|
|
|
describe("queryLogRequestHandler", function() {
|
|
it("should honnor the limit", async function() {
|
|
let result = await getControl().send(
|
|
new lib.LogQueryRequest(true, false, [], [], undefined, 10, "asc")
|
|
);
|
|
assert.equal(result.log.length, 10);
|
|
});
|
|
it("should return entries by order", async function() {
|
|
let first = await getControl().send(new lib.LogQueryRequest(true, false, [], [], undefined, 1, "asc"));
|
|
let last = await getControl().send(new lib.LogQueryRequest(true, false, [], [], undefined, 1, "desc"));
|
|
assert(first.log[0].timestamp < last.log[0].timestamp, "first log entry happened after last");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clusteriohost", function() {
|
|
describe("create-scenario", function() {
|
|
it("should create a patched scenario", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
const output = path.join("temp", "test", "patched-scenario");
|
|
await fs.rm(output, { recursive: true, force: true });
|
|
await execHost("create-scenario patched-scenario --plugins research_sync");
|
|
const control = await fs.readFile(path.join(output, "control.lua"), "utf8");
|
|
assert(control.includes('require("modules/research_sync/sync")'));
|
|
await fs.access(path.join(output, "scenario.lua"));
|
|
});
|
|
});
|
|
|
|
describe("hostUpdateEventHandler()", function() {
|
|
it("should trigger when a new host is added", async function() {
|
|
slowTest(this);
|
|
// Pairing sends an update for the new host before it connects, which is not what is tested here.
|
|
// The round trip makes sure that update has been received before clearing the updates.
|
|
await pairAltHost();
|
|
await getControl().send(new lib.HostListRequest());
|
|
getControl().hostUpdates = [];
|
|
|
|
await runWithAltHost(async () => {
|
|
await execCtl("instance create alt-test --id 99");
|
|
await execCtl(`instance assign alt-test ${altHost.hostId}`);
|
|
});
|
|
|
|
let sawUpdate = false;
|
|
let sawConnected = false;
|
|
let sawDisconnected = false;
|
|
|
|
for (let update of getControl().hostUpdates) {
|
|
if (update.name !== "alt-host") {
|
|
continue;
|
|
}
|
|
|
|
sawUpdate = true;
|
|
if (update.connected) {
|
|
sawConnected = true;
|
|
} else {
|
|
sawDisconnected = true;
|
|
}
|
|
}
|
|
|
|
assert(sawUpdate, "No host update was sent");
|
|
assert(sawConnected, "No host update with status connected was sent");
|
|
assert(sawDisconnected, "No host update with status disconnected was sent");
|
|
|
|
let result = await getControl().send(new lib.HostListRequest());
|
|
let hosts = new Map(result.map(instance => [instance.id, instance]));
|
|
assert(hosts.has(altHost.hostId), "Host list was not updated");
|
|
});
|
|
});
|
|
it("should download mods from controller", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
await runWithAltHost(async () => {
|
|
// Use the latest vesion in /dist instead of a hardcoded version
|
|
async function checkModDownloaded() {
|
|
let files = await fs.readdir(path.join("temp", "test", "alt-mods"));
|
|
return files.find(name => name.startsWith("clusterio_lib_"));
|
|
}
|
|
assert(!await checkModDownloaded(), "mod was downloaded before the test");
|
|
await execCtl("instance create alt-mod --id 98");
|
|
await execCtl(`instance assign alt-mod ${altHost.hostId}`);
|
|
await execCtl("instance save create alt-mod");
|
|
assert(await checkModDownloaded(), "mod was not downloaded by the test");
|
|
});
|
|
});
|
|
it("should auto start instances with auto_start enabled", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
this.timeout(30000); // Need an even longer timeout for this test
|
|
|
|
let hostProcess;
|
|
try {
|
|
hostProcess = await startAltHost();
|
|
await execCtl("instance create alt-start --id 97");
|
|
await execCtl(`instance config set alt-start factorio.rcon_port ${await getTestRconPort()}`);
|
|
await execCtl(`instance assign alt-start ${altHost.hostId}`);
|
|
await execCtl("instance config set alt-start instance.auto_start true");
|
|
const visibility = jsonArg({ lan: false, public: false });
|
|
await execCtlProcess(`instance config set-prop alt-start factorio.settings visibility ${visibility}`);
|
|
await execCtl("instance config set-prop alt-start factorio.settings require_user_verification false");
|
|
await stopAltHost(hostProcess);
|
|
hostProcess = await spawnAltHost();
|
|
// Stop the host immediatly to test the handling of stopping
|
|
// instances while automatically started and a save is being created
|
|
await stopAltHost(hostProcess);
|
|
hostProcess = await spawnAltHost();
|
|
let status;
|
|
// Wait for instance status to become running.
|
|
for (let i = 0; i < 100; i++) {
|
|
const instances = await getInstances();
|
|
status = instances.get(97)?.status ?? "not_present";
|
|
if (status === "running") {
|
|
break;
|
|
}
|
|
await wait(100); // Try again 10 times per second.
|
|
}
|
|
assert.equal(status, "running");
|
|
await execCtl("instance stop alt-start");
|
|
await execCtl("instance delete alt-start");
|
|
} finally {
|
|
await stopAltHost(hostProcess);
|
|
}
|
|
});
|
|
describe("config", function() {
|
|
it("can read the config file", async function() {
|
|
await execHost("config list");
|
|
});
|
|
it("should refuse to modify the config file while locked", async function() {
|
|
slowTest(this);
|
|
let hostProcess; // Alt host used since the process could get killed
|
|
const config = "alt-host-config.json";
|
|
try {
|
|
hostProcess = await startAltHost();
|
|
await assert.rejects(
|
|
execHost(`--config ${config} config set host.name ConfigEditTest`)
|
|
);
|
|
} finally {
|
|
await stopAltHost(hostProcess);
|
|
}
|
|
});
|
|
it("should modify the config file while locked if bypass option is given", async function() {
|
|
slowTest(this);
|
|
let hostProcess; // Alt host used since the process could get killed
|
|
const config = "alt-host-config.json";
|
|
try {
|
|
hostProcess = await startAltHost();
|
|
await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`);
|
|
} finally {
|
|
await stopAltHost(hostProcess);
|
|
}
|
|
|
|
const json = JSON.parse(await fs.readFile(path.join("temp", "test", config), "utf8"));
|
|
assert.equal(json["host.name"], "ConfigEditTest2");
|
|
});
|
|
});
|
|
describe("run", function() {
|
|
it("should refuse to start a second process while locked", async function() {
|
|
slowTest(this);
|
|
let hostProcess; // Alt host used since the process could get killed
|
|
const config = "alt-host-config.json";
|
|
try {
|
|
hostProcess = await startAltHost();
|
|
await assert.rejects(
|
|
execHost(`--config ${config} run`)
|
|
);
|
|
} finally {
|
|
await stopAltHost(hostProcess);
|
|
}
|
|
});
|
|
it("should start a second process while locked if bypass option is given", async function() {
|
|
slowTest(this);
|
|
let hostProcessA, hostProcessB; // Alt host used since the process could get killed
|
|
const config = "alt-host-config.json";
|
|
try {
|
|
hostProcessA = await startAltHost();
|
|
hostProcessB = await spawnNode(
|
|
"alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/
|
|
);
|
|
} finally {
|
|
await stopAltHost(hostProcessA);
|
|
await stopAltHost(hostProcessB);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("plugin system", function() {
|
|
// Exercised with test/file/test_plugin, which is a plugin that exists
|
|
// only to be loaded by these tests, see #342.
|
|
async function pluginDetails(address) {
|
|
const plugins = await getControl().sendTo(address, new lib.PluginListRequest());
|
|
const details = plugins.find(plugin => plugin.name === "test_plugin");
|
|
assert(details, `test_plugin missing from the plugin list of ${JSON.stringify(address)}`);
|
|
return details;
|
|
}
|
|
|
|
it("should load the plugin on the controller", async function() {
|
|
const details = await pluginDetails("controller");
|
|
assert.equal(details.state, "loaded", "test_plugin is not loaded on the controller");
|
|
});
|
|
|
|
it("should load the plugin on the host", async function() {
|
|
const details = await pluginDetails({ hostId });
|
|
assert.equal(details.state, "loaded", "test_plugin is not loaded on the host");
|
|
});
|
|
|
|
it("should report the plugin's title and version", async function() {
|
|
const details = await pluginDetails("controller");
|
|
assert.equal(details.title, "Test Plugin");
|
|
assert.equal(details.version, "0.0.1");
|
|
});
|
|
|
|
it("should add the plugin's command to clusterioctl", async function() {
|
|
const { stdout } = await execCtlProcess("test-plugin echo works");
|
|
assert(stdout.includes("works"), `plugin command did not print its message, got ${stdout}`);
|
|
});
|
|
|
|
it("should route a plugin request from control to the controller", async function() {
|
|
const response = await getControl().sendTo("controller", new ControllerEcho("to-controller"));
|
|
assert.equal(response, "to-controller");
|
|
});
|
|
|
|
it("should route a plugin request from control to a host", async function() {
|
|
const received = await getControl().sendTo({ hostId }, new HostEchoReceived("never-broadcast"));
|
|
assert.equal(received, false);
|
|
});
|
|
|
|
it("should deliver a plugin event sent with broadcastEventToHosts", async function() {
|
|
await getControl().sendTo("controller", new ControllerEcho("to-hosts"));
|
|
|
|
// The broadcast is sent without waiting for the hosts to receive it.
|
|
let received = false;
|
|
for (let attempt = 0; attempt < 100 && !received; attempt++) {
|
|
received = await getControl().sendTo({ hostId }, new HostEchoReceived("to-hosts"));
|
|
if (!received) {
|
|
await lib.wait(20);
|
|
}
|
|
}
|
|
assert(received, "host did not receive the event broadcast to it");
|
|
});
|
|
});
|
|
|
|
describe("clusterioctl", function() {
|
|
describe("controller config list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("controller config list");
|
|
});
|
|
it("should not leak auth_secret", async function() {
|
|
let result = await getControl().send(new lib.ControllerConfigGetRequest());
|
|
assert.equal(Object.prototype.hasOwnProperty.call(result, "controller.auth_secret"), false);
|
|
});
|
|
});
|
|
|
|
describe("controller config set", function() {
|
|
it("sets given config option", async function() {
|
|
await execCtl("controller config set controller.name Test-Cluster");
|
|
let result = await getControl().send(new lib.ControllerConfigGetRequest());
|
|
assert.equal(result["controller.name"], "Test-Cluster");
|
|
});
|
|
it("should not allow setting auth_secret", async function() {
|
|
await assert.rejects(execCtl("controller config set controller.auth_secret root"));
|
|
});
|
|
});
|
|
|
|
describe("controller plugin list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("controller plugin list");
|
|
});
|
|
});
|
|
|
|
describe("controller plugin update", function() {
|
|
it("runs", async function() {
|
|
// In dev plugins have no npm package, so best we can do is get an error from the controller
|
|
await assert.rejects(
|
|
execCtl("controller plugin update foo"),
|
|
/Plugin foo is not installed on this machine/
|
|
);
|
|
});
|
|
// Update always fails, we can not test restart option
|
|
});
|
|
|
|
describe("controller plugin install", function() {
|
|
it("runs", async function() {
|
|
// Default is to disallow updates, changing this value would require a restart
|
|
// Additionally, it can not be changed via ctl, so best we can do is get an error from the controller
|
|
await assert.rejects(
|
|
execCtl("controller plugin install foo"),
|
|
/Plugin installs are disabled on this machine/
|
|
);
|
|
});
|
|
// Install always fails, we can not test restart option
|
|
});
|
|
|
|
describe("controller plugin search", function() {
|
|
it("runs", async function() {
|
|
externalTest(this);
|
|
await execCtl("controller plugin search global_chat --page-size 5");
|
|
});
|
|
it("rejects invalid paging", async function() {
|
|
await assert.rejects(
|
|
execCtl("controller plugin search --page 0"),
|
|
/Invalid page or page size/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("controller update", function() {
|
|
it("runs", async function() {
|
|
await execCtl("controller update");
|
|
});
|
|
it("accepts --all", async function() {
|
|
await execCtl("controller update --all");
|
|
});
|
|
it("accepts --restart", async function() {
|
|
// We cannot restart the controller, so we check for controller error instead
|
|
await assert.rejects(
|
|
execCtl("controller update --restart"),
|
|
/Cannot restart, controller does not have a process monitor to restart it./
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("controller restart", function() {
|
|
it("runs", async function() {
|
|
// We cannot restart the controller, so we check for controller error instead
|
|
await assert.rejects(
|
|
execCtl("controller restart"),
|
|
/Cannot restart, controller does not have a process monitor to restart it./
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("controller mark-restart-required", function() {
|
|
it("runs", async function() {
|
|
await execCtl("controller mark-restart-required");
|
|
});
|
|
});
|
|
|
|
describe("host list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("host list");
|
|
});
|
|
});
|
|
|
|
describe("host config", function() {
|
|
it("changes host config", async function() {
|
|
await execCtl(`host config set ${hostId} host.name My-Host`);
|
|
const result = await execCtlProcess(`host config list ${hostId}`);
|
|
assert(/My-Host/.test(result.stdout), "New host name not in config output");
|
|
});
|
|
it("should not allow setting host.controller_token", async function() {
|
|
await assert.rejects(
|
|
execCtl(`host config set ${hostId} host.controller_token xyz`),
|
|
/Field 'host\.controller_token' is not accessible/,
|
|
);
|
|
});
|
|
it("should not allow setting host.id", async function() {
|
|
await assert.rejects(
|
|
execCtl(`host config set ${hostId} host.id 200`),
|
|
/Setting 'host\.id' while host is running is not supported/,
|
|
);
|
|
});
|
|
it("should not leak host.controller_token", async function() {
|
|
let result = await getControl().sendTo({ hostId }, new lib.HostConfigGetRequest());
|
|
assert.equal(Object.prototype.hasOwnProperty.call(result, "host.controller_token"), false);
|
|
});
|
|
});
|
|
|
|
describe("host plugin list", function() {
|
|
it("runs", async function() {
|
|
await execCtl(`host plugin list ${hostId}`);
|
|
});
|
|
});
|
|
|
|
describe("host plugin update", function() {
|
|
it("runs", async function() {
|
|
// In dev plugins have no npm package, so best we can do is get an error from the host
|
|
await assert.rejects(
|
|
execCtl(`host plugin update ${hostId} foo`),
|
|
/Plugin foo is not installed on this machine/
|
|
);
|
|
});
|
|
// Update always fails, we can not test restart option
|
|
});
|
|
|
|
describe("host plugin install", function() {
|
|
it("runs", async function() {
|
|
// Default is to disallow updates, changing this value would require a restart
|
|
// Additionally, it can not be changed via ctl, so best we can do is get an error from the host
|
|
await assert.rejects(
|
|
execCtl(`host plugin install ${hostId} foo`),
|
|
/Plugin installs are disabled on this machine/
|
|
);
|
|
});
|
|
// Install always fails, we can not test restart option
|
|
});
|
|
|
|
describe("host update", function() {
|
|
it("runs", async function() {
|
|
await execCtl(`host update ${hostId}`);
|
|
});
|
|
it("accepts --all", async function() {
|
|
await execCtl(`host update ${hostId} --all`);
|
|
});
|
|
it("accepts --restart", async function() {
|
|
// We cannot restart the host, so we check for host error instead
|
|
await assert.rejects(
|
|
execCtl(`host update ${hostId} --restart`),
|
|
/Cannot restart, host does not have a process monitor to restart it./
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("host restart", function() {
|
|
it("runs", async function() {
|
|
// We cannot restart the host, so we check for host error instead
|
|
await assert.rejects(
|
|
execCtl(`host restart ${hostId}`),
|
|
/Cannot restart, host does not have a process monitor to restart it./
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("host mark-restart-required", function() {
|
|
it("runs", async function() {
|
|
await execCtl(`host mark-restart-required ${hostId}`);
|
|
});
|
|
});
|
|
|
|
describe("host pending, approve and reject", function() {
|
|
let pairings = [];
|
|
afterEach(async function() {
|
|
// Stop hosts left waiting by a failed test
|
|
for (const pairing of pairings) {
|
|
pairing.abort.abort();
|
|
}
|
|
await Promise.allSettled(pairings.map(pairing => pairing.result));
|
|
pairings = [];
|
|
});
|
|
|
|
// Start pairing like a host without a user token, returns once the request is pending.
|
|
async function requestPairing(name) {
|
|
const abort = new AbortController();
|
|
let onPending;
|
|
const pending = new Promise(resolve => { onPending = resolve; });
|
|
const result = pairHost({
|
|
controllerUrl: url, name, pollIntervalMs: 100, signal: abort.signal, onPending,
|
|
});
|
|
const pairing = { abort, result, code: undefined };
|
|
pairings.push(pairing);
|
|
const ended = result.then(() => {
|
|
throw new Error("Pairing finished before the request was pending");
|
|
});
|
|
ended.catch(() => {}); // Only of interest while racing the pending request
|
|
pairing.code = await Promise.race([pending, ended]);
|
|
return pairing;
|
|
}
|
|
|
|
it("should list a waiting request and hand the host over when approved", async function() {
|
|
const control = getControl();
|
|
control.hostPairingUpdates = [];
|
|
const pairing = await requestPairing("pending-host");
|
|
assert.match(pairing.code, /^[2-9A-HJKMNP-Z]{3}-[2-9A-HJKMNP-Z]{3}$/);
|
|
|
|
const pendingOutput = await execCtlOutput("host pending");
|
|
const pendingLines = pendingOutput.split("\n");
|
|
assert(
|
|
pendingLines.some(line => line.includes(pairing.code) && line.includes("pending-host")),
|
|
`Request missing from host pending output:\n${pendingOutput}`,
|
|
);
|
|
assert(
|
|
control.hostPairingUpdates.some(update => update.code === pairing.code && !update.isDeleted),
|
|
"No pairing update was sent for the new request",
|
|
);
|
|
|
|
// Codes are matched ignoring case and separators
|
|
const typedCode = pairing.code.replace("-", "").toLowerCase();
|
|
const approveOutput = await execCtlOutput(`host approve ${typedCode} --name approved-host`);
|
|
const approved = await pairing.result;
|
|
assert.equal(approved.name, "approved-host");
|
|
assert(approveOutput.includes(String(approved.hostId)), `Host id missing from: ${approveOutput}`);
|
|
assert(
|
|
control.hostPairingUpdates.some(update => update.code === pairing.code && update.isDeleted),
|
|
"No pairing update was sent for the approved request",
|
|
);
|
|
|
|
const requests = await control.send(new lib.HostPairingListRequest());
|
|
assert(!requests.some(request => request.code === pairing.code), "Approved request is still pending");
|
|
const hosts = await control.send(new lib.HostListRequest());
|
|
assert.equal(hosts.find(host => host.id === approved.hostId)?.name, "approved-host");
|
|
assert.equal(await checkHostToken(url, approved.token), true);
|
|
|
|
await execCtl(`host revoke ${approved.hostId}`);
|
|
});
|
|
|
|
it("should make the host fail when the request is rejected", async function() {
|
|
const pairing = await requestPairing("rejected-host");
|
|
await execCtl(`host reject ${pairing.code}`);
|
|
await assert.rejects(pairing.result, pairingError(403));
|
|
|
|
const requests = await getControl().send(new lib.HostPairingListRequest());
|
|
assert(!requests.some(request => request.code === pairing.code), "Rejected request is still pending");
|
|
const hosts = await getControl().send(new lib.HostListRequest());
|
|
assert(!hosts.some(host => host.name === "rejected-host"), "Rejected host was added");
|
|
});
|
|
|
|
it("should remove the request when the host stops waiting", async function() {
|
|
const pairing = await requestPairing("cancelled-host");
|
|
pairing.abort.abort();
|
|
await assert.rejects(pairing.result, err => err.name === "AbortError");
|
|
|
|
const requests = await getControl().send(new lib.HostPairingListRequest());
|
|
assert(!requests.some(request => request.code === pairing.code), "Cancelled request is still pending");
|
|
});
|
|
|
|
it("should fail for a code that is not pending", async function() {
|
|
await assert.rejects(execCtl("host approve 222-222"), /No pending host request with code 222-222/);
|
|
await assert.rejects(execCtl("host reject 222-222"), /No pending host request with code 222-222/);
|
|
});
|
|
|
|
it("should refuse an invalid user token instead of waiting for approval", async function() {
|
|
await assert.rejects(
|
|
pairHost({ controllerUrl: url, name: "invalid-token-host", token: "invalid" }), pairingError(401)
|
|
);
|
|
const requests = await getControl().send(new lib.HostPairingListRequest());
|
|
assert(!requests.some(request => request.name === "invalid-token-host"), "Request was created");
|
|
});
|
|
|
|
it("should refuse a user token without permission to approve hosts", async function() {
|
|
await getControl().send(new lib.UserCreateRequest("pairing-player"));
|
|
try {
|
|
const token = jwt.sign(
|
|
{ aud: "user", user: "pairing-player" }, Buffer.from("TestSecretDoNotUse", "base64")
|
|
);
|
|
await assert.rejects(
|
|
pairHost({ controllerUrl: url, name: "unapproved-host", token }), pairingError(403)
|
|
);
|
|
} finally {
|
|
await getControl().send(new lib.UserDeleteRequest("pairing-player"));
|
|
}
|
|
const requests = await getControl().send(new lib.HostPairingListRequest());
|
|
assert(!requests.some(request => request.name === "unapproved-host"), "Request was created");
|
|
const hosts = await getControl().send(new lib.HostListRequest());
|
|
assert(!hosts.some(host => host.name === "unapproved-host"), "Host was added");
|
|
});
|
|
});
|
|
|
|
describe("host revoke", function() {
|
|
it("should remove the host and invalidate its token", async function() {
|
|
const pairing = await pairTestHost("revokee-host");
|
|
assert.equal(await checkHostToken(url, pairing.token), true);
|
|
await execCtl(`note set host ${pairing.hostId} short-lived`);
|
|
|
|
const control = getControl();
|
|
control.hostUpdates = [];
|
|
await execCtl(`host revoke ${pairing.hostId}`);
|
|
assert(
|
|
control.hostUpdates.some(update => update.id === pairing.hostId && update.isDeleted),
|
|
"No host update with isDeleted was sent",
|
|
);
|
|
const hosts = await control.send(new lib.HostListRequest());
|
|
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
|
assert.equal(await checkHostToken(url, pairing.token), false);
|
|
const notes = await control.send(new lib.NoteListRequest());
|
|
assert(!notes.some(note => note.id === `host/${pairing.hostId}`), "Note of the host was not removed");
|
|
});
|
|
|
|
it("should refuse while instances are assigned to the host", async function() {
|
|
const pairing = await pairTestHost("assigned-host");
|
|
// Instances can only be assigned to connected hosts, a link that accepts the assignment is enough.
|
|
const connector = new TestHostConnector(url, 0.2);
|
|
connector.token = pairing.token;
|
|
connector.hostId = pairing.hostId;
|
|
const link = new lib.Link(connector);
|
|
link.handle(lib.InstanceAssignInternalRequest, async () => {});
|
|
await connector.connect();
|
|
await execCtl("instance create revoke-assigned --id 45");
|
|
try {
|
|
await execCtl(`instance assign revoke-assigned ${pairing.hostId}`);
|
|
await assert.rejects(
|
|
execCtl(`host revoke ${pairing.hostId}`),
|
|
/Cannot revoke host assigned-host while instances are assigned to it: revoke-assigned/,
|
|
);
|
|
} finally {
|
|
await connector.disconnect();
|
|
await execCtl("instance assign revoke-assigned");
|
|
await execCtl("instance delete revoke-assigned");
|
|
}
|
|
|
|
await execCtl(`host revoke ${pairing.hostId}`);
|
|
const hosts = await getControl().send(new lib.HostListRequest());
|
|
assert(!hosts.some(host => host.id === pairing.hostId), "Host was not removed");
|
|
});
|
|
|
|
it("should stop a running host and have it ask to join again when started", async function() {
|
|
slowTest(this);
|
|
this.timeout(30000);
|
|
const config = "revoke-host-config.json";
|
|
const pairing = await pairTestHost("running-revokee");
|
|
await writeHostConfig(path.join("temp", "test", config), pairing, {
|
|
"host.factorio_directory": hostFactorioDir,
|
|
"host.instances_directory": "revoke-instances",
|
|
"host.mods_directory": "revoke-mods",
|
|
});
|
|
const hostProcess = await spawnNode(
|
|
"revoke-host:", `../../packages/host run --config ${config}`, /Started host/
|
|
);
|
|
try {
|
|
const exited = events.once(hostProcess, "exit");
|
|
await execCtl(`host revoke ${pairing.hostId}`);
|
|
assert.notEqual(
|
|
await lib.timeout(exited, 10e3, "timeout"), "timeout", "Host did not stop after being revoked"
|
|
);
|
|
} finally {
|
|
if (hostProcess.exitCode === null && hostProcess.signalCode === null) {
|
|
hostProcess.kill("SIGINT");
|
|
}
|
|
}
|
|
|
|
// Starting it again asks to join the cluster again, and it starts up once approved
|
|
const rejoining = await spawnNode(
|
|
"revoke-host:", `../../packages/host run --config ${config}`, /Waiting for approval/
|
|
);
|
|
const exited = events.once(rejoining, "exit");
|
|
try {
|
|
const started = new Promise(resolve => {
|
|
rejoining.stdout.on("data", chunk => {
|
|
if (String(chunk).includes("Started host")) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
const requests = await getControl().send(new lib.HostPairingListRequest());
|
|
const request = requests.find(entry => entry.name === "running-revokee");
|
|
assert(request, "Revoked host did not ask to join again");
|
|
const newHostId = await getControl().send(new lib.HostPairingApproveRequest(request.code));
|
|
assert.notEqual(newHostId, pairing.hostId);
|
|
assert.notEqual(
|
|
await lib.timeout(started, 10e3, "timeout"), "timeout", "Host did not start once approved"
|
|
);
|
|
await execCtl(`host revoke ${newHostId}`);
|
|
await lib.timeout(exited, 10e3, "timeout");
|
|
} finally {
|
|
if (rejoining.exitCode === null && rejoining.signalCode === null) {
|
|
rejoining.kill("SIGINT");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("instance list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("instance list");
|
|
});
|
|
});
|
|
|
|
describe("instance create", function() {
|
|
it("creates the instance", async function() {
|
|
this.timeout(6000);
|
|
await execCtl("instance create test --id 44");
|
|
await execCtl(`instance config set test factorio.rcon_port ${await getTestRconPort()}`);
|
|
let instances = await getInstances();
|
|
assert(instances.has(44), "instance was not created");
|
|
assert.equal(instances.get(44).status, "unassigned", "incorrect instance status");
|
|
|
|
// Make sure the following tests does not fail due to not having internet
|
|
const value = jsonArg({ lan: true, public: false });
|
|
await execCtlProcess(`instance config set-prop test factorio.settings visibility ${value}`);
|
|
await execCtl("instance config set-prop test factorio.settings require_user_verification false");
|
|
});
|
|
it("can clone an instance", async function() {
|
|
slowTest(this);
|
|
await execCtl("instance create testClone --id 440 --from 44");
|
|
const instances = await getInstances();
|
|
assert(instances.has(440), "instance was not created");
|
|
assert.equal(instances.get(440).status, "unassigned", "incorrect instance status");
|
|
const config = await getControl().send(new lib.InstanceConfigGetRequest(44));
|
|
assert.deepEqual(config["factorio.settings"]["visibility"], { lan: true, public: false });
|
|
assert.equal(config["factorio.settings"]["require_user_verification"], false);
|
|
});
|
|
it("errors when cloning an invalid instance", async function() {
|
|
slowTest(this);
|
|
await assert.rejects(
|
|
execCtl("instance create testClone --id 440 --from 441"),
|
|
new lib.NotFoundError("Instance with ID 441 does not exist")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("instance assign", function() {
|
|
it("creates the instance files", async function() {
|
|
await execCtl(`instance assign test ${hostId}`);
|
|
await assert.doesNotReject(
|
|
fs.access(path.join(instancesDir, "test")),
|
|
"Instance directory was not created"
|
|
);
|
|
await checkInstanceStatus(44, "stopped");
|
|
});
|
|
});
|
|
|
|
describe("instance save create", function() {
|
|
it("creates a save", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
getControl().saveUpdates = [];
|
|
await execCtl("instance save create test");
|
|
await checkInstanceStatus(44, "stopped");
|
|
});
|
|
});
|
|
|
|
describe("InstanceSaveDetailsUpdatesEvent", function() {
|
|
it("should have triggered for the created save", function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
assert.equal(getControl().saveUpdates.slice(-1)[0].updates[0].name, "world.zip");
|
|
});
|
|
});
|
|
|
|
describe("instance save list", function() {
|
|
it("lists the created save", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
let result = await execCtlProcess("instance save list test");
|
|
assert(/world\.zip/.test(result.stdout), "world.zip not present in list save output");
|
|
});
|
|
});
|
|
|
|
describe("instance export-data", function() {
|
|
it("exports the data", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
let exportPath = path.join("temp", "test", "static");
|
|
await fs.rm(exportPath, { force: true, recursive: true, maxRetries: 10 });
|
|
await execCtl("instance export-data test");
|
|
let modPack = await getControl().send(new lib.ModPackGetDefaultRequest());
|
|
let assets = modPack.exportManifest.assets;
|
|
assert(Object.keys(assets).length > 1, "Export assets is empty");
|
|
for (let key of ["settings", "prototypes", "metadata", "spritesheet", "locale"]) {
|
|
assert(assets[key], `Missing ${key} from assets`);
|
|
await assert.doesNotReject(
|
|
fs.access(path.join(exportPath, assets[key])),
|
|
`Manifest entry for ${key} was not written to filesystem`
|
|
);
|
|
}
|
|
let prototypes = JSON.parse(await fs.readFile(path.join(exportPath, assets["prototypes"])));
|
|
assert(Object.keys(prototypes).length > 50, "Expected there to be more than 50 prototype types");
|
|
await checkInstanceStatus(44, "stopped");
|
|
});
|
|
});
|
|
|
|
for (let cmd of ["start", "restart"]) {
|
|
describe(`instance ${cmd}`, function() {
|
|
requiresFactorio(this);
|
|
async function prepareToStart() {
|
|
if (cmd === "restart") {
|
|
await execCtl("instance start test");
|
|
}
|
|
}
|
|
after(async function() {
|
|
if (!hasFactorio()) {
|
|
return;
|
|
}
|
|
// It is expected that after these tests the instance is running, I dislike this dependency
|
|
// So this is here to make sure the other tests can continue any of these ones fail
|
|
const instances = await getInstances();
|
|
if (instances.get(44).status !== "running") {
|
|
await execCtl("instance start test");
|
|
}
|
|
});
|
|
it("should not hang if factorio version does not exist", async function() {
|
|
slowTest(this);
|
|
try {
|
|
await execCtl("instance config set 44 factorio.version 0.1.2");
|
|
await assert.rejects(execCtl(`instance ${cmd} test`));
|
|
|
|
} finally {
|
|
await execCtl("instance config set 44 factorio.version latest");
|
|
}
|
|
});
|
|
it("should resolve a release channel target when starting", async function() {
|
|
slowTest(this);
|
|
try {
|
|
// "experimental" is resolved to a concrete version through the
|
|
// controller's latest-releases data before the server starts.
|
|
// Whether that version is installed (or downloadable) depends on
|
|
// the host's Factorio install - CI uses a fixed direct install -
|
|
// so the start may succeed or fail, but the channel must resolve
|
|
// and the instance must reach a terminal state rather than hang.
|
|
await execCtl("instance config set 44 factorio.version experimental");
|
|
await execCtl(`instance ${cmd} test`).catch(() => {});
|
|
const status = (await getInstances()).get(44).status;
|
|
assert(
|
|
["running", "stopped"].includes(status),
|
|
`instance stuck in unexpected state '${status}'`
|
|
);
|
|
|
|
} finally {
|
|
await execCtl("instance stop 44").catch(() => {});
|
|
await execCtl("instance config set 44 factorio.version latest");
|
|
}
|
|
});
|
|
it("should not leave the instance in the stopping state if it fails", async function() {
|
|
slowTest(this);
|
|
try {
|
|
await execCtl("instance config set 44 factorio.game_port 100000");
|
|
await assert.rejects(execCtl(`instance ${cmd} test`));
|
|
await checkInstanceStatus(44, "stopped");
|
|
|
|
} finally {
|
|
await execCtl("instance config set 44 factorio.game_port");
|
|
}
|
|
});
|
|
it("starts the given save", async function() {
|
|
slowTest(this);
|
|
await prepareToStart();
|
|
await execCtl(`instance ${cmd} test --save world.zip`);
|
|
await checkInstanceStatus(44, "running");
|
|
});
|
|
it("allows having a separate console log", async function() {
|
|
slowTest(this);
|
|
await execCtl("instance stop 44");
|
|
await execCtl("instance config set 44 factorio.console_logging true");
|
|
await prepareToStart();
|
|
await execCtl(`instance ${cmd} test`);
|
|
await checkInstanceStatus(44, "running");
|
|
const checkMessage = `check${Date.now()}`;
|
|
await sendRcon(44, checkMessage);
|
|
const consoleLog = await fs.readFile(path.join("temp", "test", "instances", "test", "console.log"));
|
|
assert(consoleLog.includes(checkMessage));
|
|
await execCtl("instance config set 44 factorio.console_logging false");
|
|
});
|
|
it("copies the save if an autosave is the target", async function() {
|
|
slowTest(this);
|
|
await execCtl("instance stop 44");
|
|
let savesDir = path.join("temp", "test", "instances", "test", "saves");
|
|
await fs.copyFile(path.join(savesDir, "world.zip"), path.join(savesDir, "_autosave1.zip"));
|
|
await prepareToStart();
|
|
await execCtl(`instance ${cmd} test`);
|
|
let saves = await getControl().sendTo({ instanceId: 44 }, new lib.InstanceSaveDetailsListRequest());
|
|
let running = saves.find(s => s.loaded);
|
|
assert(running.name !== "_autosave1.zip");
|
|
});
|
|
if (cmd === "restart") {
|
|
it("fails if the server is not running", async function() {
|
|
await execCtl("instance stop test");
|
|
await checkInstanceStatus(44, "stopped");
|
|
await assert.rejects(
|
|
execCtl("instance restart test"),
|
|
/Instance is not running/,
|
|
);
|
|
await execCtl("instance start test");
|
|
});
|
|
} else {
|
|
it("fails if the server is running", async function() {
|
|
await assert.rejects(
|
|
execCtl("instance start test"),
|
|
/Instance is already running./,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
describe("instance send-rcon", function() {
|
|
requiresFactorio(this);
|
|
const plugins = [
|
|
"global_chat", "player_sync", "player_auth",
|
|
"research_sync", "statistics_exporter", "subspace_storage",
|
|
];
|
|
this.beforeAll(async function() {
|
|
this.timeout(10000);
|
|
// Disable plugins as they interfere with enable_script_commands test
|
|
for (const plugin of plugins) {
|
|
await execCtl(`instance config set test ${plugin}.load_plugin false`);
|
|
}
|
|
});
|
|
this.afterAll(async function() {
|
|
this.timeout(10000);
|
|
// Prevents cascading failure where enable_script_commands is expected to be true
|
|
await execCtl("instance config set test factorio.enable_script_commands true");
|
|
// Enable plugins after they were previously disabled
|
|
for (const plugin of plugins) {
|
|
await execCtl(`instance config set test ${plugin}.load_plugin true`);
|
|
}
|
|
});
|
|
it("sends the command", async function() {
|
|
slowTest(this);
|
|
await execCtl("instance send-rcon test technobabble");
|
|
let { log } = await getControl().send(
|
|
new lib.LogQueryRequest(false, false, [], [44], undefined, 10, "desc")
|
|
);
|
|
assert(log.some(info => /technobabble/.test(info.message)), "Command was not sent");
|
|
});
|
|
|
|
it("should trigger InstanceSaveDetailsUpdatesEvent on save", async function() {
|
|
slowTest(this);
|
|
getControl().saveUpdates = [];
|
|
await execCtl("instance send-rcon test /server-save");
|
|
let received = false;
|
|
for (let x = 0; x < 10; x++) {
|
|
if (getControl().saveUpdates.length) {
|
|
received = true;
|
|
break;
|
|
}
|
|
await wait(100);
|
|
}
|
|
assert(received, "InstanceSaveDetailsUpdatesEvent not sent");
|
|
});
|
|
it("should save the running game with save-game", async function() {
|
|
slowTest(this);
|
|
getControl().saveUpdates = [];
|
|
await execCtl("instance save-game test");
|
|
let received = false;
|
|
for (let x = 0; x < 10; x++) {
|
|
if (getControl().saveUpdates.length) {
|
|
received = true;
|
|
break;
|
|
}
|
|
await wait(100);
|
|
}
|
|
assert(received, "InstanceSaveDetailsUpdatesEvent not sent");
|
|
});
|
|
it("should prevent execution of script commands when disabled", async function() {
|
|
slowTest(this);
|
|
await execCtl("instance send-rcon test /c");
|
|
const r1 = await getControl().send(
|
|
new lib.LogQueryRequest(false, false, [], [44], undefined, 10, "desc")
|
|
);
|
|
assert(r1.log.some(info => /\[COMMAND\]/.test(info.message)), "Command was not sent");
|
|
await execCtl("instance config set test factorio.enable_script_commands false");
|
|
|
|
await assert.rejects(
|
|
execCtl("instance send-rcon test /c"),
|
|
new Error(
|
|
"Attempted to use script command while disabled. " +
|
|
"See config factorio.enable_script_commands.\nCommand: /c"
|
|
)
|
|
);
|
|
await execCtl("instance config set test factorio.enable_script_commands true");
|
|
});
|
|
});
|
|
|
|
describe("test_plugin on a running instance", function() {
|
|
requiresFactorio(this);
|
|
before(async function() {
|
|
this.timeout(20000);
|
|
if ((await getInstances()).get(44).status !== "running") {
|
|
await execCtl("instance start test");
|
|
}
|
|
});
|
|
|
|
async function findLog(filter, message) {
|
|
// Host and instance logs are forwarded to the controller asynchronously.
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
const { log } = await getControl().send(
|
|
new lib.LogQueryRequest(false, filter.controller ?? false, filter.hostIds ?? [],
|
|
filter.instanceIds ?? [], undefined, 100, "desc")
|
|
);
|
|
const entry = log.find(info => info.message === message);
|
|
if (entry) {
|
|
return entry;
|
|
}
|
|
await wait(20);
|
|
}
|
|
throw new Error(`log entry "${message}" not found`);
|
|
}
|
|
|
|
it("should route a plugin request from control to an instance", async function() {
|
|
const response = await getControl().sendTo({ instanceId: 44 }, new InstanceEcho("to-instance"));
|
|
assert.equal(response, "to-instance");
|
|
});
|
|
|
|
it("should patch the plugin's Lua module into the save", async function() {
|
|
const result = await sendRcon(44, "/sc rcon.print(remote.interfaces.test_plugin.echo)");
|
|
assert.equal(result, "true\n");
|
|
});
|
|
|
|
it("should tag plugin log entries with the plugin and where they came from", async function() {
|
|
await getControl().sendTo("controller", new ControllerEcho("log-tagging"));
|
|
await getControl().sendTo({ instanceId: 44 }, new InstanceEcho("log-tagging"));
|
|
|
|
const controllerEntry = await findLog({ controller: true }, "test_plugin controller echo log-tagging");
|
|
assert.equal(controllerEntry.plugin, "test_plugin");
|
|
assert.equal(controllerEntry.host_id, undefined);
|
|
assert.equal(controllerEntry.instance_id, undefined);
|
|
|
|
const hostEntry = await findLog({ hostIds: [hostId] }, "test_plugin host echo log-tagging");
|
|
assert.equal(hostEntry.plugin, "test_plugin");
|
|
assert.equal(hostEntry.host_id, hostId);
|
|
assert.equal(hostEntry.instance_id, undefined);
|
|
|
|
const instanceEntry = await findLog({ instanceIds: [44] }, "test_plugin instance echo log-tagging");
|
|
assert.equal(instanceEntry.plugin, "test_plugin");
|
|
assert.equal(instanceEntry.host_id, hostId);
|
|
assert.equal(instanceEntry.instance_id, 44);
|
|
});
|
|
});
|
|
|
|
describe("instance config set-prop", function() {
|
|
requiresFactorio(this);
|
|
it("applies factorio settings while running", async function() {
|
|
slowTest(this);
|
|
|
|
let testConfigs = [
|
|
// json name, value to set,
|
|
// /config name, expected result
|
|
[
|
|
"afk_autokick_interval", 2,
|
|
"afk-auto-kick", "Kick if AFK for more than 2 minutes.",
|
|
],
|
|
[
|
|
"allow_commands", "true",
|
|
"allow-commands", "Allow Lua commands: Yes.",
|
|
],
|
|
[
|
|
"autosave_interval", 17,
|
|
"autosave-interval", "Autosave every 17 minutes.",
|
|
],
|
|
[
|
|
"autosave_only_on_server", false,
|
|
"autosave-only-on-server", "Autosave only on server: false.",
|
|
],
|
|
[
|
|
"description", "A test server blah blah",
|
|
"description", "Server description: A test server blah blah",
|
|
],
|
|
[
|
|
"ignore_player_limit_for_returning_players", true,
|
|
"ignore-player-limit-for-returning-players", "Ignore player limit for returning players: true.",
|
|
],
|
|
[
|
|
"max_players", 11,
|
|
"max-players", "11",
|
|
],
|
|
[
|
|
"max_upload_slots", 7,
|
|
"max-upload-slots", "7 slots.",
|
|
],
|
|
[
|
|
"max_upload_in_kilobytes_per_second", 123,
|
|
"max-upload-speed", "123 kilobytes per second.",
|
|
],
|
|
[
|
|
"name", "A test",
|
|
"name", "Server name: A test",
|
|
],
|
|
[
|
|
"only_admins_can_pause_the_game", false,
|
|
"only-admins-can-pause", "Only admins can pause: false.",
|
|
],
|
|
[
|
|
"game_password", "secret",
|
|
"password", "The server currently has a password.",
|
|
],
|
|
[
|
|
"tags", ["clusterio", "test-tag"],
|
|
"tags", "Server tags: clusterio test-tag",
|
|
],
|
|
[
|
|
"visibility", { lan: false, public: false },
|
|
"visibility-lan", "LAN visibility: false.",
|
|
],
|
|
|
|
// Public visibility must be reset before verify can be reset
|
|
[
|
|
"require_user_verification", false,
|
|
"require-user-verification", "Verify user identity: false.",
|
|
],
|
|
];
|
|
|
|
for (let [prop, value] of testConfigs) {
|
|
const args = `test factorio.settings ${prop} ${jsonArg(value)}`;
|
|
await execCtlProcess(`instance config set-prop ${args}`);
|
|
}
|
|
|
|
// Do this afterwards to leave enough to time for the
|
|
// changes to have propogated.
|
|
for (let [, , configName, expectedResult] of testConfigs) {
|
|
assert.equal(await sendRcon(44, `/config get ${configName}`), `${expectedResult}\n`);
|
|
}
|
|
|
|
// should not change instance status
|
|
await checkInstanceStatus(44, "running");
|
|
});
|
|
it("should allow creating and removing props", async function() {
|
|
slowTest(this);
|
|
await execCtl("instance config set-prop test factorio.settings new_property new-string-value");
|
|
let config = await getControl().send(new lib.InstanceConfigGetRequest(44));
|
|
assert.equal(config["factorio.settings"]["new_property"], "new-string-value");
|
|
await execCtl("instance config set-prop test factorio.settings new_property");
|
|
config = await getControl().send(new lib.InstanceConfigGetRequest(44));
|
|
assert.equal(config["factorio.settings"]["new_property"], undefined);
|
|
});
|
|
});
|
|
|
|
describe("user set-admin/whitelisted/banned", function() {
|
|
async function getUser(name) {
|
|
return await getControl().send(new lib.UserGetRequest(name));
|
|
}
|
|
|
|
let lists = [["admin", "isAdmin"], ["whitelisted", "isWhitelisted"], ["banned", "isBanned"]];
|
|
it("should add and remove the given user to the list", async function() {
|
|
slowTest(this);
|
|
getControl().userUpdates = [];
|
|
await getControl().send(new lib.UserCreateRequest("list_test"));
|
|
let user = (await getUsers()).get("list_test");
|
|
for (let [listName, prop] of lists) {
|
|
assert.equal(user[prop], false, `unexpected ${listName} status`);
|
|
await execCtl(`user set-${listName} list_test`);
|
|
}
|
|
user = (await getUsers()).get("list_test");
|
|
for (let [listName, prop] of lists) {
|
|
assert.equal(user[prop], true, `unexpected ${listName} status`);
|
|
let remove = { admin: "--revoke", whitelisted: "--remove", banned: "--pardon" }[listName];
|
|
await execCtl(`user set-${listName} ${remove} list_test`);
|
|
}
|
|
user = (await getUsers()).get("list_test");
|
|
for (let [listName, prop] of lists) {
|
|
assert.equal(user[prop], false, `unexpected ${listName} status`);
|
|
}
|
|
assert.equal(getControl().userUpdates.length, 7);
|
|
});
|
|
it("should not create the user if not instructed to", async function() {
|
|
slowTest(this);
|
|
for (let [listName, prop] of lists) {
|
|
try {
|
|
await execCtl(`user set-${listName} no_create_test`);
|
|
} catch (err) { /* ignore */ }
|
|
}
|
|
let user = (await getUsers()).get("no_create_test");
|
|
assert.equal(user, undefined, "user was unexpectedly created");
|
|
});
|
|
it("should create the user if instructed to", async function() {
|
|
slowTest(this);
|
|
const userUpdates = [];
|
|
getControl().userUpdates = userUpdates;
|
|
for (let [listName, prop] of lists) {
|
|
await execCtl(`user set-${listName} --create test_create_${listName}`);
|
|
let user = (await getUsers()).get(`test_create_${listName}`);
|
|
assert.equal(user && user[prop], true, `user not created and added to ${listName}`);
|
|
}
|
|
assert.equal(userUpdates.length, 6); // 1 for create, 1 for setting the property
|
|
});
|
|
it("should send ban reason", async function() {
|
|
slowTest(this);
|
|
const userUpdates = [];
|
|
getControl().userUpdates = userUpdates;
|
|
await execCtl("user set-banned --create test_ban_reason --reason a-reason");
|
|
assert.equal(userUpdates.length, 2); // 1 for create, 1 for ban reason
|
|
assert.equal(userUpdates[1].banReason, "a-reason");
|
|
let user = await getUser("test_ban_reason");
|
|
assert.equal(user.banReason, "a-reason");
|
|
});
|
|
it("should send ban list commands to running instances", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
// Check that both names are not on the list
|
|
const preCommandStateLower = await sendRcon(44, "/banlist get test_rcon_ban");
|
|
const preCommandStateUpper = await sendRcon(44, "/banlist get test_RCON_ban");
|
|
assert.equal(preCommandStateLower, "test_rcon_ban is not banned.\n", "User is already banned");
|
|
assert.equal(preCommandStateUpper, "test_RCON_ban is not banned.\n", "User is already banned");
|
|
|
|
// Add the lower case name to the list
|
|
await execCtl("user set-banned --create test_rcon_ban --reason a-reason");
|
|
const addCommandStateLower = await sendRcon(44, "/banlist get test_rcon_ban");
|
|
const addCommandStateUpper = await sendRcon(44, "/banlist get test_RCON_ban");
|
|
assert.equal(addCommandStateLower, "test_rcon_ban is banned.\n", "User is not banned");
|
|
assert.equal(addCommandStateUpper, "test_RCON_ban is banned.\n", "User is not banned");
|
|
|
|
// Remove the upper case name from the list
|
|
await execCtl("user set-banned --create test_RCON_ban --pardon");
|
|
const removeCommandStateLower = await sendRcon(44, "/banlist get test_rcon_ban");
|
|
const removeCommandStateUpper = await sendRcon(44, "/banlist get test_RCON_ban");
|
|
assert.equal(removeCommandStateLower, "test_rcon_ban is not banned.\n", "User is not unbanned");
|
|
assert.equal(removeCommandStateUpper, "test_RCON_ban is not banned.\n", "User is not unbanned");
|
|
|
|
// Check it is case insensitive by checking it was converted correctly
|
|
const users = await getUsers();
|
|
assert(users.has("test_rcon_ban"), "The first User ID was not used");
|
|
assert(!users.has("test_RCON_ban"), "Username was not converted to User ID");
|
|
});
|
|
it("should send whitelist commands to running instances", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
// Check that both names are not on the list
|
|
const preCommandStateLower = await sendRcon(44, "/whitelist get test_rcon_whitelist");
|
|
const preCommandStateUpper = await sendRcon(44, "/whitelist get test_RCON_whitelist");
|
|
assert.equal(preCommandStateLower,
|
|
"test_rcon_whitelist is not whitelisted.\n", "User is already whitelisted");
|
|
assert.equal(preCommandStateUpper,
|
|
"test_RCON_whitelist is not whitelisted.\n", "User is already whitelisted");
|
|
|
|
// Add the lower case name to the list
|
|
await execCtl("user set-whitelisted --create test_rcon_whitelist");
|
|
const addCommandStateLower = await sendRcon(44, "/whitelist get test_rcon_whitelist");
|
|
const addCommandStateUpper = await sendRcon(44, "/whitelist get test_RCON_whitelist");
|
|
assert.equal(addCommandStateLower,
|
|
"test_rcon_whitelist is whitelisted.\n", "User is not whitelisted");
|
|
assert.equal(addCommandStateUpper,
|
|
"test_RCON_whitelist is whitelisted.\n", "User is not whitelisted");
|
|
|
|
// Remove the upper case name from the list
|
|
await execCtl("user set-whitelisted --create test_RCON_whitelist --remove");
|
|
const removeCommandStateLower = await sendRcon(44, "/whitelist get test_rcon_whitelist");
|
|
const removeCommandStateUpper = await sendRcon(44, "/whitelist get test_RCON_whitelist");
|
|
assert.equal(removeCommandStateLower,
|
|
"test_rcon_whitelist is not whitelisted.\n", "User is not unwhitelisted");
|
|
assert.equal(removeCommandStateUpper,
|
|
"test_RCON_whitelist is not whitelisted.\n", "User is not unwhitelisted");
|
|
|
|
// Check it is case insensitive by checking it was converted correctly
|
|
const users = await getUsers();
|
|
assert(users.has("test_rcon_whitelist"), "The first User ID was not used");
|
|
assert(!users.has("test_RCON_whitelist"), "Username was not converted to User ID");
|
|
});
|
|
it("should send admin list commands to running instances", async function() {
|
|
// Because there is no admin list command this test will be different to the other two above
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
// Check that both names are not on the list
|
|
const preCommandStateLower = await sendRcon(44, "/demote test_rcon_admin");
|
|
const preCommandStateUpper = await sendRcon(44, "/demote test_RCON_admin");
|
|
assert.equal(preCommandStateLower,
|
|
"test_rcon_admin is not in the admin list.\n",
|
|
"User is already admin"
|
|
);
|
|
assert.equal(preCommandStateUpper,
|
|
"test_RCON_admin is not in the admin list.\n",
|
|
"User is already admin"
|
|
);
|
|
|
|
// Add the lower case name to the list
|
|
await execCtl("user set-admin --create test_rcon_admin");
|
|
const addCommandStateLower = await sendRcon(44, "/promote test_rcon_admin");
|
|
const addCommandStateUpper = await sendRcon(44, "/promote test_RCON_admin");
|
|
assert.equal(addCommandStateLower,
|
|
"test_rcon_admin is already in the admin list and will be promoted upon joining the game.\n",
|
|
"User is not admin"
|
|
);
|
|
assert.equal(addCommandStateUpper,
|
|
"test_RCON_admin is already in the admin list and will be promoted upon joining the game.\n",
|
|
"User is not admin"
|
|
);
|
|
|
|
// Remove the upper case name from the list
|
|
await execCtl("user set-admin --create test_RCON_admin --revoke");
|
|
const removeCommandStateLower = await sendRcon(44, "/demote test_rcon_admin");
|
|
const removeCommandStateUpper = await sendRcon(44, "/demote test_RCON_admin");
|
|
assert.equal(removeCommandStateLower,
|
|
"test_rcon_admin is not in the admin list.\n",
|
|
"User is not demoted"
|
|
);
|
|
assert.equal(removeCommandStateUpper,
|
|
"test_RCON_admin is not in the admin list.\n",
|
|
"User is not demoted"
|
|
);
|
|
|
|
// Check it is case insensitive by checking it was converted correctly
|
|
const users = await getUsers();
|
|
assert(users.has("test_rcon_admin"), "The first User ID was not used");
|
|
assert(!users.has("test_RCON_admin"), "Username was not converted to User ID");
|
|
});
|
|
});
|
|
|
|
describe("instance extract-players", function() {
|
|
it("runs", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
await execCtl("instance extract-players test");
|
|
});
|
|
});
|
|
|
|
describe("auto pause", function() {
|
|
requiresFactorio(this);
|
|
async function getTick() {
|
|
return Number(await getControl().sendTo(
|
|
{ instanceId: 44 }, new lib.InstanceSendRconRequest("/sc rcon.print(game.tick)")
|
|
));
|
|
}
|
|
async function sendPlayerEvent(type) {
|
|
const json = JSON.stringify({ type, name: "auto_pause_player", reason: "quit" });
|
|
await getControl().sendTo(
|
|
{ instanceId: 44 },
|
|
new lib.InstanceSendRconRequest(`/sc print("\\f$ipc:player_event?j${lib.escapeString(json)}")`),
|
|
);
|
|
}
|
|
let started = false;
|
|
before(async function() {
|
|
slowTest(this);
|
|
this.timeout(60000);
|
|
started = true;
|
|
await execCtl("instance stop test");
|
|
await execCtl("instance config set-prop test factorio.settings auto_pause true");
|
|
await execCtl("instance start test");
|
|
});
|
|
after(async function() {
|
|
if (!started) {
|
|
return;
|
|
}
|
|
this.timeout(60000);
|
|
await execCtl("instance stop test");
|
|
await execCtl("instance config set-prop test factorio.settings auto_pause false");
|
|
await execCtl("instance start test");
|
|
});
|
|
it("does not tick the game while no players are online", async function() {
|
|
slowTest(this);
|
|
const start = await getTick();
|
|
await wait(6000);
|
|
assert.equal(await getTick() - start, 1);
|
|
});
|
|
it("resumes plugin work when a player joins", async function() {
|
|
slowTest(this);
|
|
const start = await getTick();
|
|
await sendPlayerEvent("join");
|
|
await wait(6000);
|
|
const ticks = await getTick() - start;
|
|
await sendPlayerEvent("leave");
|
|
assert(ticks > 3, `game ran ${ticks} ticks`);
|
|
});
|
|
});
|
|
|
|
describe("instance stop", function() {
|
|
it("stops the instance", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
await execCtl("instance stop test");
|
|
await checkInstanceStatus(44, "stopped");
|
|
});
|
|
});
|
|
|
|
describe("instance load-scenario", function() {
|
|
it("starts the instance with the given settings", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
await execCtl("instance config set 44 player_auth.load_plugin false");
|
|
await execCtl("instance config set 44 research_sync.load_plugin false");
|
|
await execCtl("instance config set 44 statistics_exporter.load_plugin false");
|
|
await execCtl("instance config set 44 subspace_storage.load_plugin false");
|
|
await execCtl("instance config set 44 player_sync.load_plugin false");
|
|
await execCtl("instance config set 44 factorio.enable_save_patching false");
|
|
|
|
await execCtl("instance start 44");
|
|
const instance = (await getInstances()).get(44);
|
|
const version = lib.integerPartialVersion(instance.factorioVersion);
|
|
const isV2 = version > lib.integerPartialVersion("2.0");
|
|
const isV2_1 = version > lib.integerPartialVersion("2.1");
|
|
const isV2_1_13 = version >= lib.integerPartialVersion("2.1.13");
|
|
const isV2_1_18 = version >= lib.integerPartialVersion("2.1.18");
|
|
let exchangeString = testStrings.modified;
|
|
if (isV2_1_18) {
|
|
exchangeString = testStrings.modified_v2_1_18;
|
|
} else if (isV2_1_13) {
|
|
exchangeString = testStrings.modified_v2_1_13;
|
|
} else if (isV2_1) {
|
|
exchangeString = testStrings.modified_v2_1;
|
|
} else if (isV2) {
|
|
exchangeString = testStrings.modified_v2;
|
|
}
|
|
// eslint-disable-next-line @stylistic/max-len
|
|
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString.replace(/[\n\r]+/g, "")}"`;
|
|
await execCtl("instance stop 44");
|
|
|
|
await execCtl(`instance load-scenario test ${args}`);
|
|
await checkInstanceStatus(44, "running");
|
|
await sendRcon(44, '/c game.print("disable achievements")');
|
|
await sendRcon(44, '/c game.print("disable achievements")');
|
|
assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n");
|
|
assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n");
|
|
if (isV2) {
|
|
assert.equal(
|
|
await sendRcon(44, "/c rcon.print(game.difficulty_settings.spoil_time_modifier)"), "1\n"
|
|
);
|
|
} else {
|
|
assert.equal(
|
|
await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n"
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("instance kill", function() {
|
|
it("kills the instance", async function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
await execCtl("instance kill test");
|
|
await checkInstanceStatus(44, "stopped");
|
|
});
|
|
});
|
|
|
|
describe("instance save upload", function() {
|
|
it("should upload a zip file", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "upload.zip"), "a test");
|
|
await execCtl("instance save upload 44 upload.zip");
|
|
await assert.doesNotReject(
|
|
fs.access(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
|
|
"file not uploaded to saves directory",
|
|
);
|
|
});
|
|
it("should reject non-zip files", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "invalid"), "a test");
|
|
await assert.rejects(execCtl("instance save upload 44 invalid"));
|
|
});
|
|
it("should reject path traversal attacks", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "upload.zip"), "a test");
|
|
await assert.rejects(execCtl("instance save upload 44 upload.zip --name ../traversal.zip"));
|
|
});
|
|
});
|
|
|
|
describe("instance save download", function() {
|
|
it("should download a save", async function() {
|
|
await fs.rm(path.join("temp", "test", "upload.zip"), { force: true });
|
|
await execCtl("instance save download 44 upload.zip");
|
|
await fs.access(path.join("temp", "test", "upload.zip"));
|
|
});
|
|
it("should error if save does not exist", async function() {
|
|
await assert.rejects(execCtl("instance save download 44 invalid"));
|
|
});
|
|
it("should error on path traversal attacks", async function() {
|
|
await assert.rejects(execCtl("instance save download 44 ../factorio-current.log"));
|
|
});
|
|
});
|
|
|
|
describe("instance save copy", function() {
|
|
it("copy a save file", async function() {
|
|
await execCtl("instance save copy 44 upload.zip copy.zip");
|
|
await fs.access(path.join("temp", "test", "instances", "test", "saves", "copy.zip"));
|
|
});
|
|
it("should error if destination exist", async function() {
|
|
await assert.rejects(execCtl("instance save copy 44 upload.zip copy.zip"));
|
|
});
|
|
it("should error if source does not exist", async function() {
|
|
await assert.rejects(execCtl("instance save copy 44 not-here.zip invalid.zip"));
|
|
});
|
|
it("should reject path traversal attacks", async function() {
|
|
this.timeout(4000);
|
|
await assert.rejects(execCtl("instance save copy 44 upload.zip ../traversal.zip"));
|
|
await assert.rejects(execCtl("instance save copy 44 ../saves/upload.zip traversal.zip "));
|
|
});
|
|
});
|
|
|
|
describe("instance save rename", function() {
|
|
it("rename a save file", async function() {
|
|
await execCtl("instance save rename 44 copy.zip rename.zip");
|
|
await assert.rejects(fs.access(path.join("temp", "test", "instances", "test", "saves", "copy.zip")));
|
|
await fs.access(path.join("temp", "test", "instances", "test", "saves", "rename.zip"));
|
|
});
|
|
it("should error if new name exist", async function() {
|
|
await assert.rejects(execCtl("instance save rename 44 upload.zip rename.zip"));
|
|
});
|
|
it("should error if old name does not exist", async function() {
|
|
await assert.rejects(execCtl("instance save rename 44 not-here.zip invalid.zip"));
|
|
});
|
|
it("should reject path traversal attacks", async function() {
|
|
this.timeout(4000);
|
|
await assert.rejects(execCtl("instance save rename 44 upload.zip ../traversal.zip"));
|
|
await assert.rejects(execCtl("instance save rename 44 ../saves/upload.zip traversal.zip "));
|
|
});
|
|
});
|
|
|
|
describe("instance save transfer", function() {
|
|
before(async function() {
|
|
slowTest(this);
|
|
await execCtlProcess("instance create spam --id 66");
|
|
await execCtlProcess(`instance assign spam ${hostId}`);
|
|
await execCtlProcess("instance create unassign --id 77");
|
|
});
|
|
after(async function() {
|
|
try {
|
|
await execCtlProcess("instance delete spam");
|
|
} catch (err) {
|
|
// Ignore
|
|
}
|
|
});
|
|
for (let remote of [false, true]) {
|
|
let pri = 44;
|
|
let sec = remote ? 88 : 66;
|
|
let priSaves = path.join("temp", "test", "instances", "test", "saves");
|
|
let secSaves = remote
|
|
? path.join("temp", "test", "alt-instances", "save-test", "saves")
|
|
: path.join("temp", "test", "instances", "spam", "saves")
|
|
;
|
|
describe(remote ? "remote" : "local", function() {
|
|
this.timeout(10000);
|
|
if (remote) {
|
|
let hostProcess;
|
|
before(async function() {
|
|
slowTest(this);
|
|
|
|
hostProcess = await startAltHost();
|
|
await execCtlProcess("instance create save-test --id 88");
|
|
await execCtlProcess(`instance assign save-test ${altHost.hostId}`);
|
|
});
|
|
after(async function() {
|
|
await stopAltHost(hostProcess);
|
|
});
|
|
}
|
|
it("should transfers a save", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await execCtlProcess(`instance save transfer ${pri} transfer.zip ${sec}`);
|
|
await assert.rejects(fs.access(path.join(priSaves, "transfer.zip")), "save still at pri");
|
|
await assert.doesNotReject(fs.access(path.join(secSaves, "transfer.zip")), "save not at sec");
|
|
await deleteSave(sec, "transfer.zip");
|
|
});
|
|
it("should support rename", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await execCtlProcess(`instance save transfer ${pri} transfer.zip ${sec} rename.zip`);
|
|
await assert.rejects(fs.access(path.join(priSaves, "transfer.zip")), "save still at pri");
|
|
assert(await (path.join(secSaves, "rename.zip")), "save not at sec");
|
|
await deleteSave(sec, "rename.zip");
|
|
});
|
|
it("should auto-rename if target exists", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await uploadSave(sec, "transfer.zip", "transfer.zip");
|
|
await execCtlProcess(`instance save transfer ${pri} transfer.zip ${sec}`);
|
|
await assert.rejects(fs.access(path.join(priSaves, "transfer.zip")), "save still at pri");
|
|
await assert.doesNotReject(fs.access(path.join(secSaves, "transfer-2.zip")), "save not at sec");
|
|
await deleteSave(sec, "transfer.zip");
|
|
await deleteSave(sec, "transfer-2.zip");
|
|
});
|
|
it("should copy when using --copy", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await execCtlProcess(`instance save transfer ${pri} transfer.zip ${sec} --copy`);
|
|
await assert.doesNotReject(fs.access(path.join(priSaves, "transfer.zip")), "save not at pri");
|
|
await assert.doesNotReject(fs.access(path.join(secSaves, "transfer.zip")), "save not at sec");
|
|
await deleteSave(pri, "transfer.zip");
|
|
await deleteSave(sec, "transfer.zip");
|
|
});
|
|
it("should auto-rename copy if target exists", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await uploadSave(sec, "transfer.zip", "existing");
|
|
await execCtlProcess(`instance save transfer ${pri} transfer.zip ${sec} --copy`);
|
|
await assert.doesNotReject(fs.access(path.join(priSaves, "transfer.zip")), "save not at pri");
|
|
assert.equal(await fs.readFile(path.join(secSaves, "transfer.zip"), "utf8"), "existing");
|
|
assert.equal(await fs.readFile(path.join(secSaves, "transfer-2.zip"), "utf8"), "transfer.zip");
|
|
await deleteSave(pri, "transfer.zip");
|
|
await deleteSave(sec, "transfer.zip");
|
|
await deleteSave(sec, "transfer-2.zip");
|
|
});
|
|
it("should fail if save does not exist", async function() {
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} not-here.zip ${sec}`));
|
|
});
|
|
it("should fail if source save name is invalid", async function() {
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} nul 111`));
|
|
});
|
|
it("should fail if target save name is invalid", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 111 nul`));
|
|
await deleteSave(pri, "transfer.zip");
|
|
});
|
|
it("should reject path traversal of source save", async function() {
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} ../saves/transfer.zip 111`));
|
|
});
|
|
it("should reject path traversal of target save", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await assert.rejects(
|
|
execCtl(`instance save transfer ${pri} transfer.zip 111 ../saves/transfer.zip`)
|
|
);
|
|
await deleteSave(pri, "transfer.zip");
|
|
});
|
|
if (!remote) {
|
|
it("should fail if source and target instance is the same instance", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip ${pri}`));
|
|
await deleteSave(pri, "transfer.zip");
|
|
});
|
|
it("should fail if source instance does not exist", async function() {
|
|
await assert.rejects(execCtl(`instance save transfer 111 not-here.zip ${sec}`));
|
|
});
|
|
it("should fail if target instance does not exist", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 111`));
|
|
await deleteSave(pri, "transfer.zip");
|
|
});
|
|
it("should fail if source instance is not assigned", async function() {
|
|
await assert.rejects(execCtl(`instance save transfer 77 transfer.zip ${pri}`));
|
|
});
|
|
it("should fail if target instance is not assigned", async function() {
|
|
await uploadSave(pri, "transfer.zip", "transfer.zip");
|
|
await assert.rejects(execCtl(`instance save transfer ${pri} transfer.zip 77`));
|
|
await deleteSave(pri, "transfer.zip");
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
});
|
|
|
|
describe("instance save delete", function() {
|
|
it("should delete a save", async function() {
|
|
await execCtl("instance save delete 44 upload.zip");
|
|
await assert.rejects(
|
|
fs.access(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
|
|
"file not deleted",
|
|
);
|
|
});
|
|
it("should error if save does not exist", async function() {
|
|
await assert.rejects(execCtl("instance save delete 44 upload.zip"));
|
|
});
|
|
it("should error on path traversal attacks", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "upload.zip"), "a test");
|
|
await assert.rejects(execCtl("instance save delete 44 ../../upload.zip"));
|
|
});
|
|
});
|
|
|
|
describe("instance delete", function() {
|
|
it("deletes the instance", async function() {
|
|
slowTest(this);
|
|
getControl().saveUpdates = [];
|
|
await execCtl("instance delete test");
|
|
await assert.rejects(fs.access(path.join(instancesDir, "test")), "Instance files was not deleted");
|
|
let instances = await getInstances();
|
|
assert(!instances.has(44), "instance was not deleted from controller");
|
|
|
|
const saveUpdates = getControl().saveUpdates;
|
|
assert.equal(saveUpdates.length, 1, "Expected one update sent on saves");
|
|
assert(saveUpdates[0].updates.length > 0, "Expected saves update to contain at least one save updated");
|
|
assert(saveUpdates[0].updates.every(update => update.isDeleted), "Expected all saves to be deleted");
|
|
});
|
|
});
|
|
|
|
describe("instanceUpdateEventHandler()", function() {
|
|
it("should have triggered for the previous instance status updates", function() {
|
|
slowTest(this);
|
|
requiresFactorio(this);
|
|
let statusesToCheck = new Set([
|
|
"unassigned", "stopped", "creating_save", "exporting_data",
|
|
"starting", "running", "stopping", "deleted", "unknown",
|
|
]);
|
|
let statusesNotSeen = new Set(statusesToCheck);
|
|
|
|
for (let update of getControl().instanceUpdates) {
|
|
assert(statusesToCheck.has(update.status), `Missing check for status ${update.status}`);
|
|
statusesNotSeen.delete(update.status);
|
|
}
|
|
|
|
assert(statusesNotSeen.size === 0, `Did not see the statuses ${[...statusesNotSeen]}`);
|
|
});
|
|
});
|
|
|
|
describe("mod-pack create", function() {
|
|
it("should create a mod-pack", async function() {
|
|
await execCtl("mod-pack create empty-pack 1.1.0");
|
|
let modPacks = await getControl().send(new lib.ModPackListRequest());
|
|
assert(modPacks.some(modPack => modPack.name === "empty-pack"), "created pack is not in the list");
|
|
});
|
|
it("should allow setting all fields", async function() {
|
|
const color = { r: 1, g: 0, b: 1, a: 0 };
|
|
await execCtlProcess(
|
|
"mod-pack create full-pack 0.17.59 " +
|
|
"--description Description " +
|
|
"--mods empty_mod:1.0.0 " +
|
|
"--bool-setting startup MyBool true " +
|
|
"--int-setting runtime-global MyInt 1235 " +
|
|
"--double-setting runtime-global MyDouble 12.25 " +
|
|
"--string-setting runtime-per-user MyString a-string " +
|
|
`--color-setting runtime-per-user MyColor ${jsonArg(color)}`
|
|
);
|
|
let modPacks = await getControl().send(new lib.ModPackListRequest());
|
|
let modPack = modPacks.find(entry => entry.name === "full-pack");
|
|
assert(modPack, "created mod pack not found");
|
|
let reference = lib.ModPack.fromJSON({ factorio_version: "0.17.59" });
|
|
reference.id = modPack.id;
|
|
reference.name = "full-pack";
|
|
reference.description = "Description";
|
|
reference.factorioVersion = "0.17.59";
|
|
reference.mods.set("empty_mod", { name: "empty_mod", enabled: true, version: "1.0.0" });
|
|
reference.settings["startup"].set("MyBool", { value: true });
|
|
reference.settings["runtime-global"].set("MyInt", { value: 1235 });
|
|
reference.settings["runtime-global"].set("MyDouble", { value: 12.25 });
|
|
reference.settings["runtime-per-user"].set("MyString", { value: "a-string" });
|
|
reference.settings["runtime-per-user"].set("MyColor", { value: color });
|
|
reference.updatedAtMs = modPack.updatedAtMs;
|
|
assert.deepEqual(modPack, reference);
|
|
});
|
|
});
|
|
|
|
describe("mod-pack list", function() {
|
|
let result = null;
|
|
it("runs", async function() {
|
|
result = await execCtlProcess("mod-pack list");
|
|
});
|
|
it("contains defaults", function() {
|
|
assert(result !== null, "Failed to return a value");
|
|
const stdout = result.stdout.trim();
|
|
assert(stdout.indexOf("Base Game") >= 0, "No base game pack");
|
|
assert(stdout.indexOf("Space Age") >= 0, "No space age pack");
|
|
});
|
|
});
|
|
|
|
describe("mod-pack show", function() {
|
|
it("runs", async function() {
|
|
await execCtl("mod-pack show empty-pack");
|
|
});
|
|
});
|
|
|
|
describe("mod-pack import/export", function() {
|
|
it("should should roundtrip a mod-pack", async function() {
|
|
slowTest(this);
|
|
let reference = lib.ModPack.fromJSON({});
|
|
reference.name = "imported-pack";
|
|
reference.description = "Description";
|
|
reference.factorioVersion = "0.17.59";
|
|
reference.mods.set("empty_mod", { name: "empty_mod", enabled: true, version: "1.0.0" });
|
|
reference.settings["startup"].set("MyBool", { value: true });
|
|
reference.settings["runtime-global"].set("MyInt", { value: 1235 });
|
|
reference.settings["runtime-global"].set("MyDouble", { value: 12.25 });
|
|
reference.settings["runtime-per-user"].set("MyString", { value: "a-string" });
|
|
await execCtl(`mod-pack import ${reference.toModPackString()}`);
|
|
const result = await execCtlProcess("mod-pack export imported-pack");
|
|
const roundtrip = lib.ModPack.fromModPackString(result.stdout.trim());
|
|
roundtrip.id = reference.id;
|
|
assert.deepEqual(roundtrip, reference);
|
|
});
|
|
});
|
|
|
|
describe("mod-pack edit", function() {
|
|
it("runs", async function() {
|
|
await execCtl("mod-pack edit full-pack --factorio-version 1.2.0");
|
|
let modPacks = await getControl().send(new lib.ModPackListRequest());
|
|
let modPack = modPacks.find(entry => entry.name === "full-pack");
|
|
assert(modPack, "created mod pack not found");
|
|
assert.equal(modPack.factorioVersion, "1.2.0");
|
|
});
|
|
});
|
|
|
|
describe("mod-pack delete", function() {
|
|
it("deletes the pack", async function() {
|
|
await execCtl("mod-pack delete full-pack");
|
|
let modPacks = await getControl().send(new lib.ModPackListRequest());
|
|
let modPack = modPacks.find(entry => entry.name === "full-pack");
|
|
assert(!modPack, "mod pack not deleted");
|
|
});
|
|
});
|
|
|
|
describe("mod upload", function() {
|
|
it("uploads a mod", async function() {
|
|
await libBuildMod.build({
|
|
build: true,
|
|
pack: true,
|
|
sourceDir: path.join("test", "file", "empty_mod"),
|
|
outputDir: path.join("temp", "test"),
|
|
});
|
|
await execCtl("mod upload empty_mod_1.0.0.zip");
|
|
await assert.doesNotReject(
|
|
fs.access(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip")),
|
|
"mod not present in mods directory",
|
|
);
|
|
});
|
|
it("rejects path traversing mods", async function() {
|
|
await libBuildMod.build({
|
|
build: true,
|
|
pack: true,
|
|
sourceDir: path.join("test", "file", "path_traversing_mod"),
|
|
outputDir: path.join("temp", "test"),
|
|
modName: "path_traversing_mod_1.0.0",
|
|
});
|
|
await fs.rm(path.join("temp", "test", "bad_path_mod_1.0.0.zip"), { force: true });
|
|
await assert.rejects(execCtl("mod upload path_traversing_mod_1.0.0.zip"));
|
|
await assert.rejects(
|
|
fs.access(path.join("temp", "test", "bad_path_mod_1.0.0.zip")),
|
|
"mod executed a path traversal attack"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("mod show", function() {
|
|
it("gives details of a mod", async function() {
|
|
let result = await execCtlProcess("mod show empty_mod 1.0.0");
|
|
let hash = await lib.hashFile(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip"));
|
|
let stat = await fs.stat(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip"));
|
|
assert.equal(
|
|
result.stdout,
|
|
"name: empty_mod\n" +
|
|
"version: 1.0.0\n" +
|
|
"title: An Empty Mod\n" +
|
|
"author: Me\n" +
|
|
"contact: \n" +
|
|
"homepage: \n" +
|
|
"description: An empty mod for testing\n" +
|
|
"factorioVersion: 1.1\n" +
|
|
"dependencies:\n" +
|
|
`size: ${stat.size}\n` +
|
|
`mtimeMs: ${stat.mtimeMs}\n` +
|
|
`sha1: ${hash}\n` +
|
|
`updatedAtMs: ${stat.mtimeMs}\n` +
|
|
"isDeleted: false\n",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("mod list", function() {
|
|
it("shows the list of mods", async function() {
|
|
let result = await execCtlProcess("mod list");
|
|
assert(result.stdout.indexOf("empty_mod") !== -1, "empty_mod is not in the list");
|
|
});
|
|
});
|
|
|
|
describe("mod search", function() {
|
|
it("searches the list of mods", async function() {
|
|
let result = await execCtlProcess("mod search 1.1 name:empty_mod");
|
|
assert(result.stdout.indexOf("empty_mod") !== -1, "empty_mod is not in the result");
|
|
});
|
|
});
|
|
|
|
describe("mod download", function() {
|
|
it("downloads a mod", async function() {
|
|
await fs.unlink(path.join("temp", "test", "empty_mod_1.0.0.zip"));
|
|
await execCtl("mod download empty_mod 1.0.0");
|
|
await assert.doesNotReject(
|
|
fs.access(path.join("temp", "test", "empty_mod_1.0.0.zip")),
|
|
"mod not downloaded to cwd",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("mod delete", function() {
|
|
it("deletes a mod", async function() {
|
|
await execCtl("mod delete empty_mod 1.0.0");
|
|
await assert.rejects(
|
|
fs.access(path.join("temp", "test", "mods", "empty_mod_1.0.0.zip")),
|
|
"mod still present in mods dir",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("modUpdateEventHandler()", function() {
|
|
it("should have triggered for the previous mod updates", function() {
|
|
let eventsToCheck = new Set(["updated", "deleted"]);
|
|
let eventsNotSeen = new Set(eventsToCheck);
|
|
|
|
for (let modUpdate of getControl().modUpdates) {
|
|
if (modUpdate.name !== "empty_mod" || modUpdate.version !== "1.0.0") {
|
|
continue;
|
|
}
|
|
if (modUpdate.isDeleted) {
|
|
eventsNotSeen.delete("deleted");
|
|
} else {
|
|
eventsNotSeen.delete("updated");
|
|
}
|
|
}
|
|
|
|
assert(eventsNotSeen.size === 0, `Did not see the events ${[...eventsNotSeen]}`);
|
|
});
|
|
});
|
|
|
|
describe("permission list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("permission list");
|
|
});
|
|
});
|
|
|
|
describe("role list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("role list");
|
|
});
|
|
});
|
|
|
|
describe("role create", function() {
|
|
it("should create the given role", async function() {
|
|
let args = "--description \"A temp role\" --permissions core.control.connect";
|
|
await execCtl(`role create temp ${args}`);
|
|
let roles = await getControl().send(new lib.RoleListRequest());
|
|
let tempRole = roles.find(role => role.name === "temp");
|
|
if (tempRole) { tempRole.updatedAtMs = 0; }
|
|
assert.deepEqual(
|
|
tempRole,
|
|
new lib.Role(5, "temp", "A temp role", new Set(["core.control.connect"]))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("role edit", function() {
|
|
it("should modify the given role", async function() {
|
|
let args = "--name new --description \"A new role\" --set-perms";
|
|
await execCtlProcess(`role edit temp ${args}`);
|
|
let roles = await getControl().send(new lib.RoleListRequest());
|
|
let newRole = roles.find(role => role.name === "new");
|
|
if (newRole) { newRole.updatedAtMs = 0; }
|
|
assert.deepEqual(newRole, new lib.Role(5, "new", "A new role", new Set()));
|
|
});
|
|
it("should add permissions with --add-perms", async function() {
|
|
let args = "--name new --add-perms core.host.list core.instance.list";
|
|
await execCtl(`role edit new ${args}`);
|
|
let roles = await getControl().send(new lib.RoleListRequest());
|
|
let newRole = roles.find(role => role.name === "new");
|
|
assert.deepEqual(newRole.permissions, new Set(["core.host.list", "core.instance.list"]));
|
|
});
|
|
it("should remove permissions with --remove-perms", async function() {
|
|
let args = "--name new --remove-perms core.host.list";
|
|
await execCtl(`role edit new ${args}`);
|
|
let roles = await getControl().send(new lib.RoleListRequest());
|
|
let newRole = roles.find(role => role.name === "new");
|
|
assert.deepEqual(newRole.permissions, new Set(["core.instance.list"]));
|
|
});
|
|
it("should grant default permissions with --grant-default", async function() {
|
|
await execCtl("role edit new --grant-default");
|
|
const control = getControl();
|
|
let roles = await control.send(new lib.RoleListRequest());
|
|
let newRole = roles.find(role => role.name === "new");
|
|
const permissions = await control.send(new lib.PermissionListRequest());
|
|
const defaultPermissions = permissions
|
|
.filter(p => p.grantByDefault)
|
|
.map(p => p.name);
|
|
assert.deepEqual(new Set(newRole.permissions), new Set(defaultPermissions));
|
|
});
|
|
});
|
|
|
|
describe("role delete", function() {
|
|
it("should delete the given role", async function() {
|
|
await execCtl("role delete new");
|
|
let roles = await getControl().send(new lib.RoleListRequest());
|
|
let newRole = roles.find(role => role.name === "new");
|
|
assert(!newRole, "Role was not deleted");
|
|
});
|
|
});
|
|
|
|
describe("note set", function() {
|
|
it("should set the note of the given resource", async function() {
|
|
await execCtl(`note set host ${hostId} basement-box`);
|
|
const notes = await getControl().send(new lib.NoteListRequest());
|
|
const note = notes.find(n => n.id === `host/${hostId}`);
|
|
assert.equal(note.content, "basement-box");
|
|
assert.equal(note.updatedBy, "test");
|
|
});
|
|
});
|
|
|
|
describe("note list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("note list");
|
|
});
|
|
it("runs with --type", async function() {
|
|
await execCtl("note list --type host");
|
|
});
|
|
});
|
|
|
|
describe("note get", function() {
|
|
it("should print the note of the given resource", async function() {
|
|
const lines = [];
|
|
const originalLog = console.log; // eslint-disable-line no-console
|
|
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
|
try {
|
|
await execCtl(`note get host ${hostId}`);
|
|
} finally {
|
|
console.log = originalLog; // eslint-disable-line no-console
|
|
}
|
|
assert.deepEqual(lines, ["basement-box"]);
|
|
});
|
|
it("should fail for a resource without a note", async function() {
|
|
await assert.rejects(
|
|
execCtl("note get host 5"),
|
|
new lib.CommandError("No note for host 5")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("note delete", function() {
|
|
it("should remove the note of the given resource", async function() {
|
|
await execCtl(`note delete host ${hostId}`);
|
|
const notes = await getControl().send(new lib.NoteListRequest());
|
|
assert(!notes.find(n => n.id === `host/${hostId}`), "Note was not deleted");
|
|
});
|
|
});
|
|
|
|
describe("user list", function() {
|
|
it("runs", async function() {
|
|
await execCtl("user list");
|
|
});
|
|
});
|
|
|
|
describe("user create", function() {
|
|
it("should create the given user", async function() {
|
|
getControl().userUpdates = [];
|
|
await execCtl("user create temp");
|
|
let users = await getControl().send(new lib.UserListRequest());
|
|
let tempUser = users.find(user => user.id === "temp");
|
|
assert(tempUser, "user was not created");
|
|
assert.equal(getControl().userUpdates.length, 1);
|
|
assert.equal(getControl().userUpdates[0].id, "temp");
|
|
});
|
|
});
|
|
|
|
describe("user revoke-token", function() {
|
|
it("should kick existing sessions for the user", async function() {
|
|
slowTest(this);
|
|
await getControl().send(new lib.UserCreateRequest("revokee"));
|
|
let connector = new TestControlConnector(url, 2);
|
|
connector.token = jwt.sign(
|
|
{ aud: "user", user: "revokee" }, Buffer.from("TestSecretDoNotUse", "base64")
|
|
);
|
|
let revokeeControl = new TestControl(connector);
|
|
await connector.connect();
|
|
connector.setClosing();
|
|
let closed = new Promise(resolve => connector.once("close", resolve));
|
|
|
|
await execCtl("user revoke-token revokee");
|
|
|
|
await closed;
|
|
});
|
|
});
|
|
|
|
describe("user set-roles", function() {
|
|
it("should set the roles on the user", async function() {
|
|
getControl().userUpdates = [];
|
|
await execCtlProcess('user set-roles temp "Cluster Admin"');
|
|
let users = await getControl().send(new lib.UserListRequest());
|
|
let tempUser = users.find(user => user.id === "temp");
|
|
assert.deepEqual(tempUser.roleIds, new Set([0]));
|
|
assert.equal(getControl().userUpdates.length, 1);
|
|
});
|
|
|
|
it("should restrict actions based on roles", async function() {
|
|
await execCtl("user set-roles temp Player");
|
|
let tempControl;
|
|
try {
|
|
let connector = new TestControlConnector(url, 2);
|
|
connector.token = jwt.sign(
|
|
{ aud: "user", user: "temp" }, Buffer.from("TestSecretDoNotUse", "base64")
|
|
);
|
|
tempControl = new TestControl(connector);
|
|
await connector.connect();
|
|
|
|
await assert.rejects(
|
|
tempControl.send(new lib.UserCreateRequest("notallowed")),
|
|
new Error("Permission denied")
|
|
);
|
|
|
|
} finally {
|
|
await tempControl.connector.disconnect();
|
|
}
|
|
|
|
let users = await getUsers();
|
|
assert(!users.has("notallowed"), "user was created when it should not be allowed");
|
|
});
|
|
});
|
|
|
|
describe("user delete", function() {
|
|
it("should delete the user", async function() {
|
|
getControl().userUpdates = [];
|
|
await execCtl("user delete temp");
|
|
let users = await getControl().send(new lib.UserListRequest());
|
|
let tempUser = users.find(user => user.id === "temp");
|
|
assert(!tempUser, "user was note deleted");
|
|
assert.equal(getControl().userUpdates.length, 1);
|
|
assert.equal(getControl().userUpdates[0].isDeleted, true);
|
|
});
|
|
});
|
|
|
|
describe("user import", function() {
|
|
beforeEach(async function() {
|
|
slowTest(this);
|
|
await execCtl("user set-admin import_control --revoke --create");
|
|
await execCtl("user set-banned import_control --pardon");
|
|
await execCtl("user set-whitelisted import_control --remove");
|
|
});
|
|
it("should import users", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-users.json"), JSON.stringify({
|
|
export_version: "2.0.0.alpha20",
|
|
users: [
|
|
{ username: "import_user_admin", is_admin: true },
|
|
{ username: "import_user_banned", is_banned: true },
|
|
{ username: "import_user_ban_reason", is_banned: true, ban_reason: "banned" },
|
|
{ username: "import_user_whitelist", is_whitelisted: true },
|
|
{ username: "import_user_none" },
|
|
{ username: "import_control", is_admin: true },
|
|
],
|
|
}));
|
|
|
|
await execCtl("user import --users import-users.json");
|
|
|
|
const users = await getUsers();
|
|
assert(users.get("import_control").isAdmin, "import_control was not admin");
|
|
assert(users.get("import_user_admin").isAdmin, "import_user_admin was not admin");
|
|
assert(users.get("import_user_banned").isBanned, "import_user_banned was not banned");
|
|
assert(
|
|
users.get("import_user_whitelist").isWhitelisted,
|
|
"import_user_whitelist was not whitelisted"
|
|
);
|
|
|
|
const userNone = users.get("import_user_none");
|
|
assert(!userNone.isAdmin, "import_user_none was admin");
|
|
assert(!userNone.isBanned, "import_user_none was banned");
|
|
assert(!userNone.isWhitelisted, "import_user_none was whitelisted");
|
|
|
|
const userBanReason = users.get("import_user_ban_reason");
|
|
assert(userBanReason.isBanned, "import_user_ban_reason was not banned");
|
|
assert.equal(userBanReason.banReason, "banned", "import_user_ban_reason had incorrect ban reason");
|
|
});
|
|
it("should import users by json format", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-users.json"), JSON.stringify({
|
|
export_version: "2.0.0.alpha20",
|
|
users: [{ username: "import_user", is_admin: true, is_whitelisted: true }],
|
|
}));
|
|
|
|
await execCtl("user import import-users.json");
|
|
const userFoo = (await getUsers()).get("import_user");
|
|
assert(userFoo.isAdmin, "import_user was not admin");
|
|
assert(userFoo.isWhitelisted, "import_user was not whitelisted");
|
|
});
|
|
it("should import bans", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-bans.json"), JSON.stringify([
|
|
{ username: "import_ban_reason", reason: "banned" },
|
|
"import_ban",
|
|
"import_control",
|
|
]));
|
|
|
|
await execCtl("user import --bans import-bans.json");
|
|
|
|
const users = await getUsers();
|
|
|
|
const userFoo = users.get("import_ban_reason");
|
|
assert(userFoo.isBanned, "import_ban_reason was not banned");
|
|
assert.equal(userFoo.banReason, "banned", "import_ban_reason had incorrect ban reason");
|
|
|
|
const userBar = users.get("import_ban");
|
|
assert(userBar.isBanned, "import_ban was not banned");
|
|
assert.equal(userBar.banReason, "", "import_ban had incorrect ban reason");
|
|
|
|
assert(users.get("import_control").isBanned, "import_control was not banned");
|
|
});
|
|
it("should import bans by filename", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-bans.json"), JSON.stringify([
|
|
"import_ban_filename",
|
|
]));
|
|
|
|
await execCtl("user import import-bans.json");
|
|
assert((await getUsers()).get("import_ban_filename").isBanned, "import_ban_filename was not banned");
|
|
});
|
|
it("should import admins", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-admins.json"), JSON.stringify([
|
|
"import_admin", "import_control",
|
|
]));
|
|
|
|
await execCtl("user import --admins import-admins.json");
|
|
const users = await getUsers();
|
|
assert(users.get("import_admin").isAdmin, "import_admin was not admin");
|
|
assert(users.get("import_control").isAdmin, "import_control was not admin");
|
|
});
|
|
it("should import admins by filename", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-admins.json"), JSON.stringify([
|
|
"import_admin_filename",
|
|
]));
|
|
|
|
await execCtl("user import import-admins.json");
|
|
assert((await getUsers()).get("import_admin_filename").isAdmin, "import_admin_filename was not admin");
|
|
});
|
|
it("should import whitelist", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-whitelist.json"), JSON.stringify([
|
|
"import_whitelist", "import_control",
|
|
]));
|
|
|
|
await execCtl("user import --whitelist import-whitelist.json");
|
|
const users = await getUsers();
|
|
assert(users.get("import_whitelist").isWhitelisted, "import_whitelist was not whitelisted");
|
|
assert(users.get("import_control").isWhitelisted, "import_control was not whitelisted");
|
|
});
|
|
it("should import whitelist by filename", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "import-whitelist.json"), JSON.stringify([
|
|
"import_whitelist_filename",
|
|
]));
|
|
|
|
await execCtl("user import import-whitelist.json");
|
|
assert(
|
|
(await getUsers()).get("import_whitelist_filename").isWhitelisted,
|
|
"import_whitelist_filename was not whitelisted"
|
|
);
|
|
});
|
|
it("should reject multiple provided options", async function() {
|
|
await assert.rejects(execCtl("user import --bans --admins import-admins.json"));
|
|
});
|
|
});
|
|
|
|
describe("user export", function() {
|
|
before(async function() {
|
|
await execCtl("user create export_control");
|
|
});
|
|
it("should export users", async function() {
|
|
await execCtl("user set-admin export_user --create");
|
|
await execCtl("user set-whitelisted export_user");
|
|
await execCtl("user export --users user-export.json");
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "user-export.json")));
|
|
assert(data.users, "Users array does not exist");
|
|
assert(!data.users.find(u => u.username === "export_control"), "Control user is present");
|
|
assert.deepEqual(data.users.find(u => u.username === "export_user"), {
|
|
username: "export_user", is_admin: true, is_whitelisted: true,
|
|
});
|
|
});
|
|
it("should export bans", async function() {
|
|
await execCtl("user set-banned export_ban --create");
|
|
await execCtl("user set-banned export_ban_reason --create --reason banned");
|
|
await execCtl("user export --bans ban-export.json");
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "ban-export.json")));
|
|
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
|
|
assert(data.indexOf("export_ban") >= 0, "export_ban is missing");
|
|
assert.deepEqual(data.find(u => typeof u === "object" && u.username === "export_ban_reason"), {
|
|
username: "export_ban_reason", reason: "banned",
|
|
});
|
|
});
|
|
it("should export admins", async function() {
|
|
await execCtl("user set-admin export_admin --create");
|
|
await execCtl("user export --admins admin-export.json");
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "admin-export.json")));
|
|
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
|
|
assert(data.indexOf("export_admin") >= 0, "export_admin is missing");
|
|
});
|
|
it("should export whitelist", async function() {
|
|
await execCtl("user set-whitelisted export_whitelist --create");
|
|
await execCtl("user export --whitelist whitelist-export.json");
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "whitelist-export.json")));
|
|
assert.equal(data.indexOf("export_control"), -1, "Control user is present");
|
|
assert(data.indexOf("export_whitelist") >= 0, "export_whitelist is missing");
|
|
});
|
|
});
|
|
|
|
describe("user restore", function() {
|
|
beforeEach(async function() {
|
|
slowTest(this);
|
|
await execCtl("user set-admin restore_control --create");
|
|
await execCtl("user set-banned restore_control");
|
|
await execCtl("user set-whitelisted restore_control");
|
|
});
|
|
it("should restore users", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "restore-users.json"), JSON.stringify({
|
|
export_version: "2.0.0.alpha20",
|
|
users: [
|
|
{ username: "restore_user", is_admin: true, is_whitelisted: true },
|
|
{ username: "restore_user_two", is_banned: true, ban_reason: "Test" },
|
|
],
|
|
}));
|
|
|
|
await execCtl("user restore restore-users.json");
|
|
const users = await getUsers();
|
|
|
|
const user = users.get("restore_user");
|
|
assert(user.isAdmin, "restore_user was not admin");
|
|
assert(user.isWhitelisted, "restore_user was not whitelisted");
|
|
|
|
const user2 = users.get("restore_user_two");
|
|
assert(user2.isBanned, "restore_user_two was not banned");
|
|
assert.equal(user2.banReason, "Test");
|
|
|
|
const control = users.get("restore_control");
|
|
assert(!control.isAdmin, "restore_control was admin");
|
|
assert(!control.isBanned, "restore_control was banned");
|
|
assert(!control.isWhitelisted, "restore_control was whitelisted");
|
|
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "users-backup.json")));
|
|
assert(data.users, "Users array does not exist");
|
|
assert.deepEqual(data.users.find(u => u.username === "restore_control"), {
|
|
username: "restore_control", is_admin: true, is_banned: true, is_whitelisted: true,
|
|
});
|
|
});
|
|
it("should restore bans", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "restore-bans.json"), JSON.stringify([
|
|
{ username: "restore_ban_reason", reason: "banned" },
|
|
"restore_ban",
|
|
]));
|
|
|
|
await execCtl("user restore --bans restore-bans.json");
|
|
|
|
const users = await getUsers();
|
|
|
|
const userFoo = users.get("restore_ban_reason");
|
|
assert(userFoo.isBanned, "restore_ban_reason was not banned");
|
|
assert.equal(userFoo.banReason, "banned", "restore_ban_reason had incorrect ban reason");
|
|
|
|
const userBar = users.get("restore_ban");
|
|
assert(userBar.isBanned, "restore_ban was not banned");
|
|
assert.equal(userBar.banReason, "", "restore_ban had incorrect ban reason");
|
|
|
|
assert(!users.get("restore_control").isBanned, "restore_control was banned");
|
|
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "bans-backup.json")));
|
|
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
|
|
});
|
|
it("should restore admins", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "restore-admins.json"), JSON.stringify([
|
|
"restore_admin",
|
|
]));
|
|
|
|
await execCtl("user restore --admins restore-admins.json");
|
|
const users = await getUsers();
|
|
|
|
assert(users.get("restore_admin").isAdmin, "restore_admin was not admin");
|
|
assert(!users.get("restore_control").isAdmin, "restore_control was admin");
|
|
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "admins-backup.json")));
|
|
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
|
|
});
|
|
it("should restore whitelist", async function() {
|
|
await fs.writeFile(path.join("temp", "test", "restore-whitelist.json"), JSON.stringify([
|
|
"restore_whitelist",
|
|
]));
|
|
|
|
await execCtl("user restore --whitelist restore-whitelist.json");
|
|
const users = await getUsers();
|
|
|
|
assert(users.get("restore_whitelist").isWhitelisted, "restore_whitelist was not whitelisted");
|
|
assert(!users.get("restore_control").isWhitelisted, "restore_control was whitelisted");
|
|
|
|
const data = JSON.parse(await fs.readFile(path.join("temp", "test", "whitelist-backup.json")));
|
|
assert(data.indexOf("restore_control") >= 0, "restore_control is missing");
|
|
});
|
|
it("should reject multiple provided options", async function() {
|
|
await assert.rejects(execCtl("user restore --bans --admins restore-admins.json"));
|
|
});
|
|
});
|
|
});
|
|
});
|