mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 23:54:52 +09:00
The plugin is now exp_discord_auth, so later Discord plugins can sit next to it. Config keys, permissions, routes and the Lua module moved with it, and the Discord redirect is now api/exp_discord_auth/callback. The controller registers its handlers and routes in the constructor and uses the express types, the instance plugin is a closure, and the Lua commands file returns its commands. The get and unlink responses carry the controller url so the in game messages can name the website. The login form asks the new status route whether Discord is configured and shows the error straight away instead of a button that fails. The button is full width like the link button, and a "Not you?" link next to the Discord name replaces the separate switch account button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
14 KiB
JavaScript
319 lines
14 KiB
JavaScript
import t from "tap";
|
|
import * as lib from "@clusterio/lib";
|
|
import { Controller } from "@clusterio/controller";
|
|
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
|
|
import * as messages from "../dist/node/messages.js";
|
|
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
|
|
|
for (const Message of pluginDeclaration.messages) {
|
|
lib.Link.register(Message);
|
|
}
|
|
lib.addPluginConfigFields([pluginDeclaration]);
|
|
|
|
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
|
|
|
|
lib.logger.silent = true;
|
|
t.after(() => {
|
|
lib.logger.silent = false;
|
|
});
|
|
|
|
const alice = { id: "100", name: "Alice" };
|
|
const bob = { id: "200", name: "Bob" };
|
|
|
|
/** Build the plugin around a real controller, which is side effect free while not started. */
|
|
async function startPlugin(t2, { config = {} } = {}) {
|
|
const controllerConfig = new lib.ControllerConfig("controller", {
|
|
// testdir() empties the folder, so a reload passes the directory in
|
|
"controller.database_directory": config["controller.database_directory"] ?? t2.testdir(),
|
|
"controller.public_url": "https://example.com/cluster/",
|
|
"exp_discord_auth.client_id": "123",
|
|
"exp_discord_auth.client_secret": "secret",
|
|
"controller.auth_secret": Buffer.from("test secret").toString("base64"),
|
|
...config,
|
|
});
|
|
const controller = new Controller(logger, [], controllerConfig);
|
|
for (const name of ["Player1", "Player2", "Player3", "Player4"]) {
|
|
controller.users.createUser(name);
|
|
}
|
|
|
|
const plugin = await ControllerPlugin.fromContext({ plugin: { name: "exp_discord_auth" }, controller, logger });
|
|
t2.teardown(() => clearInterval(plugin.cleanupInterval));
|
|
return { plugin, controller };
|
|
}
|
|
|
|
/** Go through the Discord callback as the given Discord user, returning the session id. */
|
|
async function discordLogin(plugin, discordUser) {
|
|
plugin.fetchDiscordUser = async () => discordUser;
|
|
const result = await plugin.callback({ code: "c", state: "s" }, "s");
|
|
return decodeURIComponent(new URL(result.redirect).hash.split("=")[1]);
|
|
}
|
|
|
|
/** Get a code on the web and use it in game as playerName. */
|
|
async function link(plugin, session, playerName) {
|
|
const { json } = await plugin.linkCode(session);
|
|
return plugin.handleLinkRequest(new messages.DiscordLinkRequest(playerName, json.code.toLowerCase()));
|
|
}
|
|
|
|
t.test("entrypoint", async t2 => {
|
|
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
|
|
const controller = new Controller(logger, [], controllerConfig);
|
|
await entrypoint({ controller, logger, plugin: { name: "exp_discord_auth" }, metrics: undefined });
|
|
for (const hook of ["save", "shutdown"]) {
|
|
t2.ok([...controller.hooks[hook].attached].includes("exp_discord_auth"), `attached to ${hook}`);
|
|
}
|
|
await controller.hooks.shutdown.invoke();
|
|
});
|
|
|
|
t.test("oauth", t2 => {
|
|
t2.test(".authorize() redirects to Discord with a state cookie scoped to the callback", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const result = await plugin.authorize();
|
|
const url = new URL(result.redirect);
|
|
t3.equal(url.origin + url.pathname, "https://discord.com/oauth2/authorize");
|
|
t3.equal(url.searchParams.get("client_id"), "123");
|
|
t3.equal(url.searchParams.get("redirect_uri"), "https://example.com/cluster/api/exp_discord_auth/callback");
|
|
const state = url.searchParams.get("state");
|
|
t3.match(result.cookie, `exp_discord_auth_state=${state}; Path=/cluster/api/exp_discord_auth/callback`);
|
|
t3.match(result.cookie, "Secure");
|
|
});
|
|
|
|
t2.test(".status() tells the login form whether Discord is configured", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
t3.same(await plugin.status(), { status: 200, json: { configured: true } });
|
|
const { plugin: unconfigured } = await startPlugin(t3, { config: { "exp_discord_auth.client_id": null } });
|
|
t3.same(await unconfigured.status(), { status: 200, json: { configured: false } });
|
|
});
|
|
|
|
t2.test(".authorize() without credentials goes back with an error", async t3 => {
|
|
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.client_secret": null } });
|
|
const result = await plugin.authorize();
|
|
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_auth_error=/);
|
|
});
|
|
|
|
t2.test(".callback() rejects a state which does not match the cookie", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
|
for (const cookie of [undefined, "other"]) {
|
|
const result = await plugin.callback({ code: "c", state: "s" }, cookie);
|
|
t3.match(result.redirect, "#exp_discord_auth_error=");
|
|
}
|
|
t3.equal(plugin.sessions.size, 0);
|
|
});
|
|
|
|
t2.test(".callback() reports a failed token exchange as an error", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
plugin.fetchDiscordUser = async () => { throw new Error("401"); };
|
|
const result = await plugin.callback({ code: "c", state: "s" }, "s");
|
|
t3.match(result.redirect, "#exp_discord_auth_error=");
|
|
});
|
|
|
|
t2.test(".callback() starts a session", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
const result = await plugin.listAccounts(session);
|
|
t3.same(result, { status: 200, json: { discord: alice, accounts: [], max_accounts: 3 } });
|
|
});
|
|
|
|
t2.test(".listAccounts() rejects unknown and expired sessions", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
t3.equal((await plugin.listAccounts("nope")).status, 401);
|
|
const session = await discordLogin(plugin, alice);
|
|
plugin.sessions.get(session).expiresMs = Date.now() - 1;
|
|
t3.equal((await plugin.listAccounts(session)).status, 401);
|
|
});
|
|
t2.end();
|
|
});
|
|
|
|
t.test("linking", t2 => {
|
|
t2.test("a code links the player who uses it, and they can then log in", async t3 => {
|
|
const { plugin, controller } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
t3.same(await link(plugin, session, "Player1"), alice);
|
|
t3.same((await plugin.listAccounts(session)).json.accounts, ["Player1"]);
|
|
|
|
const result = await plugin.login(session, "player1");
|
|
t3.equal(result.status, 200);
|
|
const payload = JSON.parse(Buffer.from(result.json.token.split(".")[1], "base64url").toString());
|
|
t3.equal(payload.user, controller.users.getByName("Player1").id);
|
|
t3.notOk(plugin.sessions.has(session), "session is used up");
|
|
});
|
|
|
|
t2.test("a code can only be used once", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
const { json } = await plugin.linkCode(session);
|
|
await plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code));
|
|
await t3.rejects(
|
|
plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player2", json.code)),
|
|
/invalid or has expired, get a new one from https:\/\/example\.com\/cluster\//,
|
|
);
|
|
});
|
|
|
|
t2.test("asking twice gives the same code", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
t3.equal((await plugin.linkCode(session)).json.code, (await plugin.linkCode(session)).json.code);
|
|
});
|
|
|
|
t2.test("expired codes are refused", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
const { json } = await plugin.linkCode(session);
|
|
plugin.codes.get(json.code).expiresMs = Date.now() - 1;
|
|
await t3.rejects(plugin.handleLinkRequest(new messages.DiscordLinkRequest("Player1", json.code)));
|
|
});
|
|
|
|
t2.test("several accounts up to the limit", async t3 => {
|
|
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 2 } });
|
|
const session = await discordLogin(plugin, alice);
|
|
await link(plugin, session, "Player1");
|
|
await link(plugin, session, "Player2");
|
|
await t3.rejects(link(plugin, session, "Player3"), /already has 2 Factorio accounts/);
|
|
t3.same(await link(plugin, session, "PLAYER1"), alice, "relinking a linked account is fine at the limit");
|
|
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1", "Player2"]);
|
|
});
|
|
|
|
t2.test("linking moves the account away from another Discord account", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
|
await link(plugin, await discordLogin(plugin, bob), "Player1");
|
|
t3.notOk(plugin.accounts.has(alice.id), "empty account is removed");
|
|
t3.same(plugin.accounts.get(bob.id).factorioNames, ["Player1"]);
|
|
});
|
|
|
|
t2.test(".login() refuses accounts not linked, missing users and banned users", async t3 => {
|
|
const { plugin, controller } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
t3.equal((await plugin.login(session, "Player2")).status, 403);
|
|
|
|
await link(plugin, session, "Player1");
|
|
controller.users.getByNameMutable("Player1").set("isBanned", true);
|
|
t3.equal((await plugin.login(session, "Player1")).status, 403);
|
|
|
|
await link(plugin, session, "Ghost");
|
|
t3.equal((await plugin.login(session, "Ghost")).status, 404);
|
|
});
|
|
|
|
t2.test("unlink and get", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
|
const url = "https://example.com/cluster/";
|
|
const get = name => plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest(name));
|
|
const unlink = name => plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest(name));
|
|
t3.same(await get("player1"), { link: alice, url });
|
|
t3.same(await unlink("Player1"), { link: alice, url });
|
|
t3.same(await get("Player1"), { link: null, url });
|
|
t3.same(await unlink("Player1"), { link: null, url });
|
|
});
|
|
|
|
t2.test("links are saved and loaded", async t3 => {
|
|
const dir = t3.testdir();
|
|
const config = { "controller.database_directory": dir };
|
|
const { plugin } = await startPlugin(t3, { config });
|
|
await link(plugin, await discordLogin(plugin, alice), "Player1");
|
|
await plugin.onShutdown();
|
|
|
|
const { plugin: reloaded } = await startPlugin(t3, { config });
|
|
t3.same(reloaded.accounts.get(alice.id).factorioNames, ["Player1"]);
|
|
});
|
|
|
|
t2.test(".removeExpired() drops old sessions and codes", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const session = await discordLogin(plugin, alice);
|
|
await plugin.linkCode(session);
|
|
plugin.removeExpired(Date.now() + 60 * 60e3);
|
|
t3.equal(plugin.sessions.size, 0);
|
|
t3.equal(plugin.codes.size, 0);
|
|
});
|
|
t2.end();
|
|
});
|
|
|
|
t.test("linking from the user page", t2 => {
|
|
/** Start a link for playerName and follow authorize, returning the OAuth state. */
|
|
async function start(plugin, playerName) {
|
|
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest(playerName));
|
|
const result = await plugin.authorize(ticket);
|
|
return new URL(result.redirect).searchParams;
|
|
}
|
|
|
|
t2.test("links the Discord account and goes back to the user page", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const params = await start(plugin, "Player1");
|
|
t3.equal(params.get("prompt"), null, "consent screen is shown");
|
|
plugin.fetchDiscordUser = async () => alice;
|
|
const result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
|
t3.equal(result.redirect, "https://example.com/cluster/users/Player1/view");
|
|
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player1"]);
|
|
t3.equal(plugin.sessions.size, 0, "no login session is started");
|
|
t3.equal(plugin.pendingLinks.size, 0);
|
|
});
|
|
|
|
t2.test("a ticket can only be used once", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const ticket = await plugin.handleLinkStartRequest(new messages.DiscordLinkStartRequest("Player1"));
|
|
await plugin.authorize(ticket);
|
|
const result = await plugin.authorize(ticket);
|
|
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/users#exp_discord_auth_error=Discord%20link/);
|
|
});
|
|
|
|
t2.test("errors go back to the user page", async t3 => {
|
|
const { plugin } = await startPlugin(t3, { config: { "exp_discord_auth.max_accounts": 1 } });
|
|
await link(plugin, await discordLogin(plugin, alice), "Player2");
|
|
|
|
let params = await start(plugin, "Player1");
|
|
let result = await plugin.callback({ error: "access_denied", state: params.get("state") }, params.get("state"));
|
|
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=Discord%20login%20was%20cancelled$/);
|
|
|
|
params = await start(plugin, "Player1");
|
|
plugin.fetchDiscordUser = async () => alice;
|
|
result = await plugin.callback({ code: "c", state: params.get("state") }, params.get("state"));
|
|
t3.match(result.redirect, /\/users\/Player1\/view#exp_discord_auth_error=.*already%20has%201/);
|
|
t3.same(plugin.accounts.get(alice.id).factorioNames, ["Player2"]);
|
|
});
|
|
|
|
t2.test("a state without the cookie does not link", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const params = await start(plugin, "Player1");
|
|
plugin.fetchDiscordUser = async () => t3.fail("should not fetch");
|
|
const result = await plugin.callback({ code: "c", state: params.get("state") }, undefined);
|
|
t3.match(result.redirect, "/users/Player1/view#exp_discord_auth_error=");
|
|
t3.notOk(plugin.accounts.has(alice.id));
|
|
});
|
|
t2.end();
|
|
});
|
|
|
|
t.test("permissions", t2 => {
|
|
const user = (name, granted = []) => ({
|
|
name,
|
|
checkPermission(permission) {
|
|
if (!granted.includes(permission)) {
|
|
throw new lib.PermissionError("denied");
|
|
}
|
|
},
|
|
});
|
|
const request = name => ({ data: { player_name: name } });
|
|
|
|
t2.test("DiscordLinkStartRequest", async t3 => {
|
|
const permission = messages.DiscordLinkStartRequest.permission;
|
|
t3.doesNotThrow(() => permission(user("Alice"), request("alice")), "own account");
|
|
t3.throws(
|
|
() => permission(
|
|
user("Alice", ["exp_discord_auth.link.get", "exp_discord_auth.link.delete"]), request("Bob"),
|
|
),
|
|
"never someone else's",
|
|
);
|
|
});
|
|
|
|
for (const Request of [messages.DiscordLinkGetRequest, messages.DiscordUnlinkRequest]) {
|
|
t2.test(Request.name, async t3 => {
|
|
const permission = Request === messages.DiscordUnlinkRequest
|
|
? "exp_discord_auth.link.delete"
|
|
: "exp_discord_auth.link.get";
|
|
t3.doesNotThrow(() => Request.permission(user("Alice"), request("alice")), "own link");
|
|
t3.throws(() => Request.permission(user("Alice"), request("Bob")), "someone else's");
|
|
t3.doesNotThrow(() => Request.permission(user("Alice", [permission]), request("Bob")), "with permission");
|
|
});
|
|
}
|
|
t2.end();
|
|
});
|