mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Reverse the direction of the API for sending messages from
messageDefinition.send(link, { content });
to
link.send(new MessageClass(content));
and convert all message definitions to use the JSON serialisable class
pattern and explicit registration of message handlers, along with a
end-to-end based routing of messages instead of link to link.
The experience I gained from the existing way messages were defined and
sent over links was that it was difficult to understand for people
unfamiliar with the system. The automatic registration of message
handlers ment that code was being called as if by magic from nowhere
with no easy way to discover it. The manual forwarding and rules for
what can be sent where and how obscured its working.
The use of pure JSON as both input and output from message handlers made
it tedious to work with. Data had to be manually converted between the
program representation and the over the wire format, which made handler
code unplesant to write. Particularly because the over the wire format
used snake_case while the program code uses camelCase for variables.
The idea behind the decision to do it this way was to make it more clear
which data came from the wire and which from the program, and while this
worked it didn't make message handlers any nicer to write.
The direction of the API and magic registration of message handlers also
meant that a future migration to TypeScript would be troublesome. While
it may be possible to implement, the degree of TypeScript magic required
would go starkly against the principle of keep it simple stupid.
By refactoring to using the JSON serialisable class pattern most of
these issues go away. There is no longer a need to distinguish the on
wire format of messages with the program representation because the only
place that deals with the wire format of messages are the toJSON and
fromJSON methods on the classes themselves. Message handlers will work
with proper instances of classes instead of raw data, something that
makes them easier to write, and trivial to type with TypeScript.
End-to-end routing also simplifies sending messages that need to do
mulitple hops to reach the destination. And removes the need for having
all messages be specified in plugin's info module, as they can be routed
without having to be parsed completely.
The explicit registration of message handlers is something that I
thought would be tedious boilerplate code that could be automated away.
But experience now shows it to be the opposite. Allthough dull
repetition, the purpose it serves is to link together the code in a way
that humans can easily find. With explicit registration searching the
name of the message handler in the editor will show the registration that
ties it together with the type of message it handles and the kind of
registration. With implicit registration searching the name of the
message handler turns up only the handler with no clue as to what calls
it.
439 lines
16 KiB
JavaScript
439 lines
16 KiB
JavaScript
"use strict";
|
|
const assert = require("assert").strict;
|
|
const phin = require("phin");
|
|
const jwt = require("jsonwebtoken");
|
|
|
|
const mock = require("../../../test/mock");
|
|
|
|
const controller = require("../controller");
|
|
const instance = require("../instance");
|
|
const info = require("../info");
|
|
const { FetchPlayerCodeRequest, SetVerifyCodeRequest } = require("../messages");
|
|
const libData = require("@clusterio/lib/data");
|
|
const libErrors = require("@clusterio/lib/errors");
|
|
|
|
|
|
describe("player_auth", function() {
|
|
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("ControllerPlugin", function() {
|
|
let controllerPlugin;
|
|
let controllerUrl;
|
|
before(async function() {
|
|
controllerPlugin = await mock.createControllerPlugin(controller.ControllerPlugin, info);
|
|
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_length", 10);
|
|
controllerPlugin.controller.mockConfigEntries.set("player_auth.code_timeout", 1);
|
|
controllerUrl = await controllerPlugin.controller.startServer();
|
|
});
|
|
after(async function() {
|
|
if (controllerPlugin) {
|
|
await controllerPlugin.controller.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) {
|
|
controllerPlugin.controller.instances.set(id, {
|
|
config: {
|
|
get(field) {
|
|
if (field === "player_auth.load_plugin") {
|
|
return load;
|
|
} else if (field === "factorio.settings") {
|
|
return { name };
|
|
}
|
|
throw new Error(`field ${field} not implemented`);
|
|
},
|
|
},
|
|
status,
|
|
});
|
|
}
|
|
addInstance(1, "running", true, "running loaded");
|
|
addInstance(2, "stopped", true, "stopped loaded");
|
|
addInstance(3, "running", false, "running unloaded");
|
|
addInstance(4, "stopped", false, "stopped unloaded");
|
|
addInstance(5, "running", true, undefined);
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/servers`,
|
|
parse: "json",
|
|
});
|
|
assert.deepEqual(result.body, ["running loaded", "unnamed server"]);
|
|
for (let id of [1, 2, 3, 4, 5]) {
|
|
controllerPlugin.controller.instances.delete(id);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("/api/player_auth/player_code", function() {
|
|
it("should return 400 on invalid json", async function() {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/player_code`,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
data: "invalid",
|
|
});
|
|
assert.equal(result.statusCode, 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"]]) {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/player_code`,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
data: JSON.stringify(obj),
|
|
});
|
|
assert.equal(result.statusCode, 400);
|
|
}
|
|
});
|
|
it("should return 400 if player_code is not a string", async function() {
|
|
for (let obj of [true, false, null, 123, [], {}]) {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/player_code`,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
data: JSON.stringify({ player_code: obj }),
|
|
});
|
|
assert.equal(result.statusCode, 400);
|
|
}
|
|
});
|
|
it("should return invalid player_code if the code is expired", async function() {
|
|
let expires = Date.now() - 1000;
|
|
controllerPlugin.players.set("expired", { playerCode: "expried", verifyCode: null, expires });
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/player_code`,
|
|
method: "POST",
|
|
data: { player_code: "expired" },
|
|
parse: "json",
|
|
});
|
|
assert.deepEqual(result.body, { error: true, message: "invalid player_code" });
|
|
});
|
|
it("should return a verify code and token if code is valid", async function() {
|
|
let expires = Date.now() + 1000;
|
|
controllerPlugin.players.set("valid", { playerCode: "valid", verifyCode: null, expires });
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/player_code`,
|
|
method: "POST",
|
|
data: { player_code: "valid" },
|
|
parse: "json",
|
|
});
|
|
assert.equal(typeof result.body.verify_code, "string");
|
|
assert.equal(typeof result.body.verify_token, "string");
|
|
});
|
|
});
|
|
|
|
describe("/api/player_auth/verify", function() {
|
|
it("should return 400 on invalid json", async function() {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
data: "invalid",
|
|
});
|
|
assert.equal(result.statusCode, 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"]]) {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
data: JSON.stringify(obj),
|
|
});
|
|
assert.equal(result.statusCode, 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"]) {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: {
|
|
player_code: "str",
|
|
verify_code: "str",
|
|
verify_token: "str",
|
|
[field]: obj,
|
|
},
|
|
});
|
|
assert.equal(result.statusCode, 400);
|
|
}
|
|
}
|
|
});
|
|
it("should return error if verify_token is not valid", async function() {
|
|
async function verify(tokenParams, error) {
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: {
|
|
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")),
|
|
},
|
|
parse: "json",
|
|
});
|
|
assert.deepEqual(result.body.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() {
|
|
let expires = Date.now() - 1000;
|
|
controllerPlugin.players.set("expired", { playerCode: "expried", verifyCode: "verify", expires });
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: {
|
|
player_code: "expired",
|
|
verify_code: "verify",
|
|
verify_token: jwt.sign({
|
|
aud: "player_auth.verify_code",
|
|
player_code: "expired",
|
|
verify_code: "verify",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
parse: "json",
|
|
});
|
|
assert.deepEqual(result.body, { error: true, message: "invalid player_code" });
|
|
});
|
|
it("should return verified false if verify code has not yet been set", async function() {
|
|
let expires = Date.now() + 1000;
|
|
controllerPlugin.players.set("unverified", { playerCode: "unverified", verifyCode: null, expires });
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: {
|
|
player_code: "unverified",
|
|
verify_code: "verify",
|
|
verify_token: jwt.sign({
|
|
aud: "player_auth.verify_code",
|
|
player_code: "unverified",
|
|
verify_code: "verify",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
parse: "json",
|
|
});
|
|
assert.deepEqual(result.body, { verified: false });
|
|
});
|
|
it("should return error if user is missing", async function() {
|
|
let expires = Date.now() + 1000;
|
|
controllerPlugin.players.set("missing", { playerCode: "missing", verifyCode: "verify", expires });
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: {
|
|
player_code: "missing",
|
|
verify_code: "verify",
|
|
verify_token: jwt.sign({
|
|
aud: "player_auth.verify_code",
|
|
player_code: "missing",
|
|
verify_code: "verify",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
parse: "json",
|
|
});
|
|
assert.deepEqual(result.body, { error: true, message: "invalid user" });
|
|
});
|
|
it("should return verified true with token if valid verification", async function() {
|
|
let expires = Date.now() + 1000;
|
|
controllerPlugin.players.set("player", { playerCode: "player", verifyCode: "verify", expires });
|
|
let result = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: {
|
|
player_code: "player",
|
|
verify_code: "verify",
|
|
verify_token: jwt.sign({
|
|
aud: "player_auth.verify_code",
|
|
player_code: "player",
|
|
verify_code: "verify",
|
|
}, Buffer.from("TestSecretDoNotUse", "base64")),
|
|
},
|
|
parse: "json",
|
|
});
|
|
assert.equal(result.body.verified, true);
|
|
assert.deepEqual(typeof result.body.token, "string", "missing token");
|
|
});
|
|
});
|
|
|
|
describe(".handleFetchPlayerCodeRequest()", function() {
|
|
it("should return a code", async function() {
|
|
let result = await controllerPlugin.handleFetchPlayerCodeRequest(
|
|
new FetchPlayerCodeRequest("test")
|
|
);
|
|
assert(typeof result.player_code === "string", "no code returned");
|
|
assert(result.player_code.length === 10, "incorrect code length returned");
|
|
let expires = controllerPlugin.players.get("test").expires;
|
|
let msFromExpected = Math.abs(expires - Date.now() - 1000);
|
|
assert(msFromExpected < 100, `expiry time expected outside window (${msFromExpected}ms)`);
|
|
});
|
|
});
|
|
|
|
describe(".handleSetVerifyCodeRequest()", function() {
|
|
it("should throw if player does not exist", async function() {
|
|
await assert.rejects(
|
|
controllerPlugin.handleSetVerifyCodeRequest(
|
|
new SetVerifyCodeRequest("invalid", "invalid")
|
|
),
|
|
new libErrors.RequestError("invalid player")
|
|
);
|
|
});
|
|
it("should throw if player code has expired", async function() {
|
|
let expires = Date.now() - 1000;
|
|
controllerPlugin.players.set("expired", { playerCode: "expried", verifyCode: null, expires });
|
|
await assert.rejects(
|
|
controllerPlugin.handleSetVerifyCodeRequest(
|
|
new SetVerifyCodeRequest("expired", "expired")
|
|
),
|
|
new libErrors.RequestError("invalid player")
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("integration", function() {
|
|
it("should verify a full login flow", async function() {
|
|
let app = controllerPlugin.controller.app;
|
|
let { player_code } = await controllerPlugin.handleFetchPlayerCodeRequest(
|
|
new FetchPlayerCodeRequest("test")
|
|
);
|
|
|
|
let playerCodeResult = await phin({
|
|
url: `${controllerUrl}/api/player_auth/player_code`,
|
|
method: "POST",
|
|
data: { player_code },
|
|
parse: "json",
|
|
});
|
|
|
|
let { verify_code, verify_token } = playerCodeResult.body;
|
|
await controllerPlugin.handleSetVerifyCodeRequest(
|
|
new SetVerifyCodeRequest("test", verify_code)
|
|
);
|
|
|
|
let verifyResult = await phin({
|
|
url: `${controllerUrl}/api/player_auth/verify`,
|
|
method: "POST",
|
|
data: { player_code, verify_code, verify_token },
|
|
parse: "json",
|
|
});
|
|
assert.equal(verifyResult.body.verified, true);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("instance.js", function() {
|
|
describe("class InstancePlugin", function() {
|
|
let instancePlugin;
|
|
before(async function() {
|
|
instancePlugin = await mock.createInstancePlugin(instance.InstancePlugin, info);
|
|
});
|
|
|
|
describe(".handleEvent()", async function() {
|
|
describe("open_dialog", async function() {
|
|
it("should call /web-login error if not connected to controller", async function() {
|
|
instancePlugin.instance.server.reset();
|
|
instancePlugin.host.connector.connected = false;
|
|
await instancePlugin.handleEvent({ type: "open_dialog", player: "test" });
|
|
let command = instancePlugin.instance.server.rconCommands[0];
|
|
instancePlugin.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() {
|
|
instancePlugin.instance.server.reset();
|
|
instancePlugin.instance.connector.once("send", message => {
|
|
instancePlugin.instance.connector.emit("message",
|
|
new libData.MessageResponseError(1, message.dst, message.src,
|
|
new libData.ResponseError("controller error")
|
|
)
|
|
);
|
|
});
|
|
await instancePlugin.handleEvent({ type: "open_dialog", player: "test" });
|
|
let command = instancePlugin.instance.server.rconCommands[0];
|
|
assert.equal(command, "/web-login error test controller error");
|
|
});
|
|
it("should call /web-login open after a valid response from the controller", async function() {
|
|
instancePlugin.instance.server.reset();
|
|
instancePlugin.instance.connector.once("send", message => {
|
|
instancePlugin.instance.connector.emit("message",
|
|
new libData.MessageResponse(1, message.dst, message.src,
|
|
new FetchPlayerCodeRequest.Response("code", "controller-url")
|
|
)
|
|
);
|
|
});
|
|
await instancePlugin.handleEvent({ type: "open_dialog", player: "test" });
|
|
let command = instancePlugin.instance.server.rconCommands[0];
|
|
assert.equal(command, "/web-login open test controller-url code");
|
|
});
|
|
});
|
|
describe("open_dialog", async function() {
|
|
it("should call /web-login code_set after a valid response from the controller", async function() {
|
|
instancePlugin.instance.server.reset();
|
|
instancePlugin.instance.connector.once("send", message => {
|
|
instancePlugin.instance.connector.emit("message",
|
|
new libData.MessageResponse(1, message.dst, message.src)
|
|
);
|
|
});
|
|
await instancePlugin.handleEvent(
|
|
{ type: "set_verify_code", player: "test", verify_code: "verify" }
|
|
);
|
|
let command = instancePlugin.instance.server.rconCommands[0];
|
|
assert.equal(command, "/web-login code_set test");
|
|
});
|
|
it("should call /web-login error after error from the controller", async function() {
|
|
instancePlugin.instance.server.reset();
|
|
instancePlugin.instance.connector.once("send", message => {
|
|
instancePlugin.instance.connector.emit("message",
|
|
new libData.MessageResponseError(1, message.dst, message.src,
|
|
new libData.ResponseError("controller error")
|
|
)
|
|
);
|
|
});
|
|
await instancePlugin.handleEvent(
|
|
{ type: "set_verify_code", player: "test", verify_code: "verify" }
|
|
);
|
|
let command = instancePlugin.instance.server.rconCommands[0];
|
|
assert.equal(command, "/web-login error test controller error");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|