mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-14 00:14:52 +09:00
The user page gets a Link Discord button. It asks the controller for a one-time ticket bound to the logged in user, then goes through the authorize route with it. The callback links the Discord account to that user directly, without the in-game code, and goes back to the user page. The link flow always shows the Discord consent screen. The README covers registering the redirect URL and the config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
13 KiB
JavaScript
306 lines
13 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.client_id": "123",
|
|
"exp_discord.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" }, 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" }, metrics: undefined });
|
|
for (const hook of ["save", "shutdown"]) {
|
|
t2.ok([...controller.hooks[hook].attached].includes("exp_discord"), `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/callback");
|
|
const state = url.searchParams.get("state");
|
|
t3.match(result.cookie, `exp_discord_state=${state}; Path=/cluster/api/exp_discord/callback`);
|
|
t3.match(result.cookie, "Secure");
|
|
});
|
|
|
|
t2.test(".authorize() without credentials goes back with an error", async t3 => {
|
|
const { plugin } = await startPlugin(t3, { config: { "exp_discord.client_secret": null } });
|
|
const result = await plugin.authorize();
|
|
t3.match(result.redirect, /^https:\/\/example\.com\/cluster\/#exp_discord_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_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_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/,
|
|
);
|
|
});
|
|
|
|
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.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");
|
|
t3.same(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("player1")), alice);
|
|
t3.same(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), alice);
|
|
t3.equal(await plugin.handleLinkGetRequest(new messages.DiscordLinkGetRequest("Player1")), null);
|
|
t3.equal(await plugin.handleUnlinkRequest(new messages.DiscordUnlinkRequest("Player1")), null);
|
|
});
|
|
|
|
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_error=Discord%20link%20expired/);
|
|
});
|
|
|
|
t2.test("errors go back to the user page", async t3 => {
|
|
const { plugin } = await startPlugin(t3, { config: { "exp_discord.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_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_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_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.link.get", "exp_discord.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.link.delete"
|
|
: "exp_discord.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();
|
|
});
|