import path from "node:path"; import fs from "node:fs/promises"; import assert from "node:assert/strict"; import jwt from "jsonwebtoken"; import { Role, SubscribableDatastore, ControllerConfig } from "@clusterio/lib"; import { UserRecord, UserManager } from "@clusterio/controller"; const TEMP_DIR = path.join("temp", "test", "UserManager"); describe("controller/UserManager", function () { /** @type {SubscribableDatastore} */ let records; /** @type {SubscribableDatastore} */ let roles; /** @type {ControllerConfig} */ let config; /** @type {UserManager} */ let userManager; beforeEach(async function () { records = new SubscribableDatastore(); roles = new SubscribableDatastore(); config = new ControllerConfig("controller", { "controller.default_role_id": null, "controller.auth_secret": Buffer.from("mysecret").toString("base64"), }); userManager = new UserManager(records, roles, config); await fs.rm(TEMP_DIR, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(TEMP_DIR, { recursive: true }); }); describe(".has() / .get() / .getMutable() / .hasName() / .getByName() / .getByNameMutable()", function () { it("should return user when exists", function () { const user = userManager.createUser("Alice"); assert.deepEqual(userManager.has(user.id), true); assert.deepEqual(userManager.get(user.id), user); assert.deepEqual(userManager.getMutable(user.id), user); assert.deepEqual(userManager.hasName("Alice"), true); assert.deepEqual(userManager.getByName("Alice"), user); assert.deepEqual(userManager.getByNameMutable("Alice"), user); }); it("should return undefined when user does not exist", function () { assert.equal(userManager.has("nonexistent"), false); assert.equal(userManager.get("nonexistent"), undefined); assert.equal(userManager.hasName("nonexistent"), false); assert.equal(userManager.getByName("nonexistent"), undefined); }); }); describe(".values() / .valuesMutable()", function () { it("should iterate all users", function () { const u1 = userManager.createUser("Alice"); const u2 = userManager.createUser("Bob"); const values = Array.from(userManager.values()); const valuesMutable = Array.from(userManager.valuesMutable()); assert.deepEqual(values, [u1, u2]); assert.deepEqual(valuesMutable, [u1, u2]); }); }); describe(".createUser()", function () { it("should create and store a new user", function () { const user = userManager.createUser("Charlie"); assert.deepEqual(userManager.getByName("Charlie"), user); assert.deepEqual(records.getMutable(user.id).toJSON(), user.toJSON()); }); it("should throw if user already exists", function () { userManager.createUser("Dave"); assert.throws(() => userManager.createUser("Dave"), /already exists/); }); it("should assign default role if configured and exists", function () { roles.set(new Role(1, "admin")); config.set("controller.default_role_id", 1); const user = userManager.createUser("Eve"); assert(user.roleIds.has(1)); }); it("should set tokenValidAfter to the current time", function () { const before = Math.floor(Date.now() / 1000); const user = userManager.createUser("Mallory"); assert(user.tokenValidAfter >= before, "tokenValidAfter predates the call"); assert(user.tokenValidAfter <= Math.floor(Date.now() / 1000), "tokenValidAfter is in the future"); }); it("should invalidate tokens issued before a user of the same name was recreated", function () { const user = userManager.createUser("Niaj"); const oldTokenIat = user.tokenValidAfter - 60; userManager.deleteUser(user); // WsServer.registerControl rejects the token when iat < tokenValidAfter const recreated = userManager.createUser("Niaj"); assert(oldTokenIat < recreated.tokenValidAfter, "token from before the deletion is still valid"); }); }); describe(".getOrCreateUser()", function () { it("should return existing user", function () { const existing = userManager.createUser("Frank"); const found = userManager.getOrCreateUser("Frank"); assert.deepEqual(found, existing); }); it("should create user if missing", function () { const found = userManager.getOrCreateUser("Grace"); assert(userManager.getByName("Grace")); assert.deepEqual(found, userManager.getByName("Grace")); }); }); describe(".deleteUser()", function () { it("should delete user from records", function () { const user = userManager.createUser("Heidi"); assert(userManager.getByName("Heidi")); userManager.deleteUser(user); assert.equal(userManager.getByName("Heidi"), undefined); }); }); describe(".signUserToken()", function () { it("should return a valid JWT for user", function () { const user = userManager.createUser("Ivan"); const token = userManager.signUserToken(user); const payload = jwt.verify( token, Buffer.from(config.get("controller.auth_secret"), "base64") ); const { iat, ...rest } = payload; assert.equal(typeof iat, "number"); assert.deepEqual(rest, { aud: "user", user: user.id }); }); }); describe(".verifyUserToken()", function () { const secret = () => Buffer.from(config.get("controller.auth_secret"), "base64"); it("should return the user the token was signed for", function () { const user = userManager.createUser("Judy"); assert.equal(userManager.verifyUserToken(userManager.signUserToken(user)).id, user.id); }); it("should reject a token for an unknown user", function () { const token = userManager.signUserToken({ id: "nobody" }); assert.throws(() => userManager.verifyUserToken(token), new Error("invalid user")); }); it("should reject a token issued before tokenValidAfter", function () { const user = userManager.createUser("Mallory"); const token = jwt.sign({ aud: "user", user: user.id, iat: user.tokenValidAfter - 1 }, secret()); assert.throws(() => userManager.verifyUserToken(token), new Error("invalid token")); }); it("should reject host tokens and bad signatures", function () { const user = userManager.createUser("Niaj"); const hostToken = jwt.sign({ aud: "host", host: 5 }, secret()); assert.throws(() => userManager.verifyUserToken(hostToken), /audience invalid/); const forged = jwt.sign({ aud: "user", user: user.id }, "wrong secret"); assert.throws(() => userManager.verifyUserToken(forged), /invalid signature/); }); }); describe(".clearStatsOfInstance()", function () { it("should clear instance stats for all users", function () { const instanceId = 42; records.set(UserRecord.fromJSON({ name: "Jack", instance_stats: [[instanceId, { join_count: 1 }]] })); records.set(UserRecord.fromJSON({ name: "Jill", instance_stats: [[instanceId, { join_count: 1 }]] })); userManager.clearStatsOfInstance(instanceId); for (const user of userManager.values()) { assert(!user.instanceStats.has(instanceId), `${user.id} still has instanceStats for ${instanceId}`); } }); }); describe("static attemptMigrateLegacyUsersFile()", function () { const usersFile = path.join(TEMP_DIR, "users.json"); const rolesFile = path.join(TEMP_DIR, "roles.json"); it("should do nothing if file does not exist", async function () { await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile); }); it("should do nothing if roles key missing", async function () { await fs.writeFile(usersFile, JSON.stringify({ users: [] })); await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile); const content = JSON.parse(await fs.readFile(usersFile)); assert.deepEqual(content, { users: [] }); }); it("should throw if users key missing in legacy file", async function () { await fs.writeFile(usersFile, JSON.stringify({ roles: [] })); await assert.rejects( () => UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile), /Error: Legacy users json does not contain users property/ ); }); it("should migrate valid legacy file", async function () { const legacy = { roles: [{ id: 1 }, { id: 2 }], users: [{ name: "Karl" }] }; await fs.writeFile(usersFile, JSON.stringify(legacy)); await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile); const writtenRoles = JSON.parse(await fs.readFile(rolesFile)); const writtenUsers = JSON.parse(await fs.readFile(usersFile)); assert.deepEqual(writtenRoles, [{ id: 1 }, { id: 2 }]); assert.deepEqual(writtenUsers, [{ name: "Karl" }]); }); }); });