mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
470 lines
18 KiB
JavaScript
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.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() {
|
|
// 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");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|