Files
2026-10-08 05:15:00 +00:00

440 lines
17 KiB
JavaScript

import assert from "node:assert/strict";
import jwt from "jsonwebtoken";
import * as mock from "../../../test/mock.js";
import * as controller from "../dist/node/controller.js";
import * as instancePlugin from "../dist/node/instance.js";
import { plugin as info } from "../dist/node/index.js";
import { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } from "../dist/node/messages.js";
import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js";
import * as lib from "@clusterio/lib";
function postJSON(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("player_auth", function() {
describe("messages.js", function() {
describe("PlayerAuthServer", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(PlayerAuthServer, testMatrix(
["server1", "server2"], // name
[undefined, "127.0.0.1", "10.0.0.1:1234"], // address
[undefined, "1.1.0", "2.0.0"], // version
));
});
});
describe("FetchPlayerCodeResponse", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(FetchPlayerCodeRequest.Response, testMatrix(
["abc123", "player-code"], // playerCode
["http://localhost", "https://example.com"], // controllerUrl
));
});
});
describe("FetchPlayerCodeRequest", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(FetchPlayerCodeRequest, testMatrix(
["player1", "player2"], // player
));
});
});
describe("SetVerifyCodeRequest", function() {
it("should be round trip json serialisable", function() {
testRoundTripJsonSerialisable(SetVerifyCodeRequest, testMatrix(
["player1", "player2"], // player
["code123", "verify456"], // verifyCode
));
});
});
});
describe("controller.js", function() {
describe("generateCode()", function() {
it("should generate a code of the given length", async function() {
let code = await controller._generateCode(20);
assert.equal(typeof code, "string");
assert.equal(code.length, 20, "incorrect length");
});
it("should only use the easy to recognize letters in the alphabet", async function() {
let chars = new Set();
for (let i = 0; i < 20; i++) {
let code = await controller._generateCode(15);
for (let char of code) {
chars.add(char);
}
}
for (let char of chars) {
assert(/[A-Za-z0-9]/.test(char), `Contained non-alphanumeric letter ${char}`);
assert(/[^ji1lI0oOQ2Z]/.test(char), `Contained banned letter ${char}`);
}
});
});
describe("entrypoint", function() {
let mockController;
let controllerUrl;
let endpoint;
let fetchPlayerCode;
let setVerifyCode;
// Adds a pending code for player through the request handlers
async function addPlayer(player, { timeout = 1, verifyCode } = {}) {
mockController.mockConfigEntries.set("player_auth.code_timeout", timeout);
try {
const { playerCode } = await fetchPlayerCode(new FetchPlayerCodeRequest(player));
if (verifyCode) {
await setVerifyCode(new SetVerifyCodeRequest(player, verifyCode));
}
return playerCode;
} finally {
mockController.mockConfigEntries.set("player_auth.code_timeout", 1);
}
}
function signVerifyToken(playerCode, verifyCode) {
return jwt.sign({
aud: "player_auth.verify_code",
player_code: playerCode,
verify_code: verifyCode,
}, Buffer.from("TestSecretDoNotUse", "base64"));
}
before(async function() {
mockController = await mock.loadControllerPlugin(controller.default, info);
mockController.mockConfigEntries.set("player_auth.code_length", 10);
mockController.mockConfigEntries.set("player_auth.code_timeout", 1);
mockController.mockConfigEntries.set("player_auth.show_connect_address", true);
fetchPlayerCode = mock.getHandler(mockController, FetchPlayerCodeRequest);
setVerifyCode = mock.getHandler(mockController, SetVerifyCodeRequest);
controllerUrl = await mockController.startServer();
});
after(async function() {
if (mockController) {
await mockController.stopServer();
}
});
describe("/api/player_auth/servers", function() {
it("should return a list of running servers with player_auth loaded", async function() {
function addInstance(id, status, load, name, version, gamePort, host=1) {
mockController.instances.records.set(id, {
config: {
get(field) {
if (field === "player_auth.load_plugin") {
return load;
}
if (field === "factorio.settings") {
return { name };
}
if (field === "instance.id") {
return id;
}
if (field === "instance.assigned_host") {
return host;
}
throw new Error(`field ${field} not implemented`);
},
},
status,
gamePort,
factorioVersion: version,
});
}
addInstance(1, "running", true, "running loaded", "1.1.1", 34197);
addInstance(2, "stopped", true, "stopped loaded", "1.1.2", 34197);
addInstance(3, "running", false, "running unloaded", "1.1.3", 34197);
addInstance(4, "stopped", false, "stopped unloaded", "1.1.4", 34197);
addInstance(5, "running", true, undefined, "1.1.5", 34197);
addInstance(6, "running", true, "no port", "1.1.6", undefined);
addInstance(7, "running", true, "no host", "1.1.7", 34197, null);
addInstance(8, "running", true, "invalid host", "1.1.8", 34197, 2);
addInstance(9, "running", true, "no version", undefined, 34197);
mockController.hosts.set(1, {
publicAddress: "127.0.0.1",
});
const result = await fetch(`${controllerUrl}/api/player_auth/servers`);
assert.deepEqual(await result.json(), [
{ name: "running loaded", address: "127.0.0.1:34197", factorioVersion: "1.1.1" },
{ name: "unnamed server", address: "127.0.0.1:34197", factorioVersion: "1.1.5" },
{ name: "no port", address: "127.0.0.1", factorioVersion: "1.1.6" },
{ name: "no version", address: "127.0.0.1:34197" },
]);
});
});
describe("/api/player_auth/player_code", function() {
before(function() {
endpoint = `${controllerUrl}/api/player_auth/player_code`;
});
it("should return 400 on invalid json", async function() {
const result = await postJSON(endpoint, "invalid");
assert.equal(result.status, 400);
});
it("should return 400 on valid json which is not an object", async function() {
for (let obj of [true, false, null, "string", 123, ["an", "array"]]) {
const result = await postJSON(endpoint, obj);
assert.equal(result.status, 400);
}
});
it("should return 400 if player_code is not a string", async function() {
for (let obj of [true, false, null, 123, [], {}]) {
const result = await postJSON(endpoint, { player_code: obj });
assert.equal(result.status, 400);
}
});
it("should return invalid player_code if the code is expired", async function() {
const playerCode = await addPlayer("expired", { timeout: -1 });
const result = await postJSON(endpoint, { player_code: playerCode });
assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" });
});
it("should return a verify code and token if code is valid", async function() {
const playerCode = await addPlayer("valid");
const result = await postJSON(endpoint, { player_code: playerCode });
const body = await result.json();
assert.equal(typeof body.verify_code, "string");
assert.equal(typeof body.verify_token, "string");
});
});
describe("/api/player_auth/verify", function() {
before(function() {
endpoint = `${controllerUrl}/api/player_auth/verify`;
});
it("should return 400 on invalid json", async function() {
const result = await postJSON(endpoint, "invalid");
assert.equal(result.status, 400);
});
it("should return 400 on valid json which is not an object", async function() {
for (let obj of [true, false, null, "string", 123, ["an", "array"]]) {
const result = await postJSON(endpoint, obj);
assert.equal(result.status, 400);
}
});
it("should return 400 if player_code, verify_code or verify_token are not strings", async function() {
for (let obj of [true, false, null, 123, [], {}]) {
for (let field of ["player_code", "verify_code", "verify_token"]) {
const result = await postJSON(endpoint, {
player_code: "str",
verify_code: "str",
verify_token: "str",
[field]: obj,
});
assert.equal(result.status, 400);
}
}
});
it("should return error if verify_token is not valid", async function() {
async function verify(tokenParams, error) {
const result = await postJSON(endpoint, {
player_code: "player",
verify_code: "verify",
verify_token: jwt.sign({
aud: "player_auth.verify_code",
player_code: "player",
verify_code: "verify",
...tokenParams,
}, Buffer.from(tokenParams.secret || "TestSecretDoNotUse", "base64")),
});
assert.deepEqual((await result.json()).error, true);
}
await verify({ secret: "InvalidSecret" });
await verify({ verify_code: "bad" });
await verify({ player_code: "bad" });
await verify({ aud: "not_player_auth" });
});
it("should return invalid player_code if the code is expired", async function() {
const playerCode = await addPlayer("expired", { timeout: -1 });
const result = await postJSON(endpoint, {
player_code: playerCode,
verify_code: "verify",
verify_token: signVerifyToken(playerCode, "verify"),
});
assert.deepEqual(await result.json(), { error: true, message: "invalid player_code" });
});
it("should return verified false if verify code has not yet been set", async function() {
const playerCode = await addPlayer("unverified");
const result = await postJSON(endpoint, {
player_code: playerCode,
verify_code: "verify",
verify_token: signVerifyToken(playerCode, "verify"),
});
assert.deepEqual(await result.json(), { verified: false });
});
it("should return error if user is missing", async function() {
const playerCode = await addPlayer("missing", { verifyCode: "verify" });
const result = await postJSON(endpoint, {
player_code: playerCode,
verify_code: "verify",
verify_token: signVerifyToken(playerCode, "verify"),
});
assert.deepEqual(await result.json(), { error: true, message: "invalid user" });
});
it("should return verified true with token if valid verification", async function() {
const playerCode = await addPlayer("player", { verifyCode: "verify" });
const result = await postJSON(endpoint, {
player_code: playerCode,
verify_code: "verify",
verify_token: signVerifyToken(playerCode, "verify"),
});
const body = await result.json();
assert.equal(body.verified, true);
assert.deepEqual(typeof body.token, "string", "missing token");
});
});
describe("FetchPlayerCodeRequest handler", function() {
it("should return a code", async function() {
let result = await fetchPlayerCode(new FetchPlayerCodeRequest("test"));
assert(typeof result.playerCode === "string", "no code returned");
assert(result.playerCode.length === 10, "incorrect code length returned");
assert.equal(result.controllerUrl, "http://controller.example/");
});
it("should expire the code after the timeout", async function() {
const start = Date.now();
const playerCode = await addPlayer("test", { timeout: 10 });
const end = Date.now();
const result = await postJSON(
`${controllerUrl}/api/player_auth/player_code`, { player_code: playerCode }
);
const { exp } = jwt.decode((await result.json()).verify_token);
assert(exp >= Math.floor((start + 10e3) / 1000), "expiry before expected window");
assert(exp <= Math.floor((end + 10e3) / 1000), "expiry after expected window");
});
});
describe("SetVerifyCodeRequest handler", function() {
it("should throw if player does not exist", async function() {
await assert.rejects(
setVerifyCode(new SetVerifyCodeRequest("invalid", "invalid")),
new lib.RequestError("invalid player")
);
});
it("should throw if player code has expired", async function() {
await addPlayer("expired", { timeout: -1 });
await assert.rejects(
setVerifyCode(new SetVerifyCodeRequest("expired", "expired")),
new lib.RequestError("invalid player")
);
});
});
describe("integration", function() {
it("should verify a full login flow", async function() {
let { playerCode } = await fetchPlayerCode(new FetchPlayerCodeRequest("test"));
const playerCodeResult = await postJSON(
`${controllerUrl}/api/player_auth/player_code`,
{ player_code: playerCode }
);
let { verify_code, verify_token } = await playerCodeResult.json();
await setVerifyCode(new SetVerifyCodeRequest("test", verify_code));
const user = mockController.users.getByName("test");
const token = mockController.users.signUserToken(user);
const verifyResult = await postJSON(
`${controllerUrl}/api/player_auth/verify`,
{ player_code: playerCode, verify_code, verify_token }
);
assert.equal(verifyResult.status, 200);
assert.deepEqual(await verifyResult.json(), { verified: true, token: token });
});
});
});
});
describe("instance.js", function() {
describe("entrypoint", function() {
let instance;
let host;
before(async function() {
try {
lib.registerPluginMessages([info]);
} catch (err) {
// Already registered by the full test suite
}
({ instance, host } = await mock.loadInstancePlugin(instancePlugin.default, info));
});
// Emits an ipc-player_auth event and returns the first RCON command it sent
async function emitIpc(event) {
instance.server.reset();
instance.server.emit("ipc-player_auth", event);
while (!instance.server.rconCommands.length) {
await new Promise(resolve => setImmediate(resolve));
}
return instance.server.rconCommands[0];
}
describe("ipc-player_auth", function() {
describe("open_dialog", function() {
it("should call /web-login error if not connected to controller", async function() {
host.connector.connected = false;
let command = await emitIpc({ type: "open_dialog", player: "test" });
host.connector.connected = true;
assert.equal(command, "/web-login error test login is temporarily unavailable");
});
it("should call /web-login error after error from the controller", async function() {
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponseError(1, message.dst, message.src,
new lib.ResponseError("controller error")
)
);
});
let command = await emitIpc({ type: "open_dialog", player: "test" });
assert.equal(command, "/web-login error test controller error");
});
it("should call /web-login open after a valid response from the controller", async function() {
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponse(1, message.dst, message.src,
new FetchPlayerCodeRequest.Response("code", "controller-url")
)
);
});
let command = await emitIpc({ type: "open_dialog", player: "test" });
assert.equal(command, "/web-login open test controller-url code");
});
});
describe("set_verify_code", function() {
it("should call /web-login code_set after a valid response from the controller", async function() {
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponse(1, message.dst, message.src)
);
});
let command = await emitIpc({ type: "set_verify_code", player: "test", verify_code: "verify" });
assert.equal(command, "/web-login code_set test");
});
it("should call /web-login error after error from the controller", async function() {
instance.connector.once("send", message => {
instance.connector.emit("message",
new lib.MessageResponseError(1, message.dst, message.src,
new lib.ResponseError("controller error")
)
);
});
let command = await emitIpc({ type: "set_verify_code", player: "test", verify_code: "verify" });
assert.equal(command, "/web-login error test controller error");
});
});
});
});
});
});