Files
factorio-clusterio/test/controller/UserManager.test.js
T
bbassieandClaude Opus 5 1da7c1da67 Terminate deleted user sessions immediately
Drop the special case that deferred terminating the connection issuing
the delete, so deleting yourself behaves the same as revoke-token and
every session of the deleted user is closed before the handler returns.

Set tokenValidAfter to the current time in createUser instead of 0.
Deleting a user and recreating it under the same name left tokens issued
before the deletion valid, as the check is iat < tokenValidAfter. Doing
it in createUser covers every path that makes a user. A token signed in
the same second as creation is still accepted, so create-admin and the
other production paths sign after the user exists and are unaffected.

The integration harness signed the control token at import time, before
bootstrap create-admin created the user. Sign it in the root before hook
instead and reuse it in link.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:31:09 +00:00

190 lines
7.0 KiB
JavaScript

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<UserRecord>} */
let records;
/** @type {SubscribableDatastore<Role>} */
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(".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" }]);
});
});
});