Files
factorio-clusterio/plugins/player_auth/test/plugin.js
T
bbassieandClaude Opus 5.5 1c7c00b033 Keep error classes across links
Error responses now carry the registered name of the thrown error's
class, and the requester rejects with an instance of that class instead
of a generic RequestError. Error classes are registered like message
classes, and plugins list their own in `errors`.

RequestError is replaced by a hierarchy following the HTTP status codes:
ClientError (ValidationError, ForbiddenError, NotFoundError,
TimeoutError, ConflictError) and ServerError (InternalServerError).
PermissionError is PermissionDeniedError and the config errors
InvalidValue, InvalidField and InvalidAccess gain an Error suffix.

Link logs only errors whose class is not registered. These arrive on the
other side as InternalServerError with the original message and stack,
so an error forwarded through the controller is logged once, where it
was thrown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:03:13 +00:00

470 lines
18 KiB
JavaScript

import assert from "node:assert/strict";
import { mock as nodeMock } from "node:test";
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;
let now;
let dateNow;
beforeEach(function() {
// Keep code expiry and JWT timestamps independent of HTTP request timing.
now = Date.now();
dateNow = nodeMock.method(Date, "now", () => now);
});
afterEach(function() {
dateNow.mock.restore();
});
// 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.NotFoundError("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.NotFoundError("invalid player")
);
});
});
describe("integration", function() {
it("should verify a full login flow", async function() {
// A one-second timeout leaves only 1ms before the JWT's rounded expiry here.
now = Math.floor(now / 1000) * 1000 + 999;
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 });
});
it("should reject a verification token at its expiry", async function() {
const playerCode = await addPlayer("test");
const playerCodeResult = await postJSON(
`${controllerUrl}/api/player_auth/player_code`, { player_code: playerCode }
);
const { verify_code, verify_token } = await playerCodeResult.json();
await setVerifyCode(new SetVerifyCodeRequest("test", verify_code));
now = jwt.decode(verify_token).exp * 1000;
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(), { error: true, message: "jwt expired" });
});
});
});
});
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");
});
});
});
});
});
});