From 2235294c59a34ea100b1aada2a2bb436f9ce2512 Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:04:37 +0000 Subject: [PATCH] Refactor user and instance manager (#837) * Refactor UserManager to use Datastore * Rename lib.User to UserDetails also adds to/fromUserDetails * Fix existing tests * Add user class tests * Add tests for finaliseUsers * Move HostDetails to own file with tests * Move InstanceDetails to its own file with tests * Rename HostInfo and add tests * Rename InstanceInfo and add tests - Also changes the names of the host properties related to instances - Also removes the need to specify the location of InstanceInfo * Make json round trip test validate schema * Add InstanceManager * Fix lint errors * Extra tests * Add final test coverage * Add one last test to InstanceManager * Response to comments * Use generator rather than return anonumous generator Co-authored-by: Hornwitser * Fix lint issue with generator --------- Co-authored-by: Hornwitser --- packages/controller/controller.ts | 28 +- packages/controller/index.ts | 8 +- .../controller/src/BaseControllerPlugin.ts | 8 +- packages/controller/src/ControlConnection.ts | 105 +++-- packages/controller/src/Controller.ts | 265 ++++--------- packages/controller/src/ControllerUser.ts | 96 ----- packages/controller/src/HostConnection.ts | 84 ++-- .../src/{HostInfo.ts => HostRecord.ts} | 68 +++- packages/controller/src/InstanceManager.ts | 274 ++++++++++++++ .../{InstanceInfo.ts => InstanceRecord.ts} | 72 ++-- packages/controller/src/User.ts | 146 +++++++ packages/controller/src/UserManager.ts | 180 +++++---- packages/controller/src/UserRecord.ts | 102 +++++ packages/controller/src/WsServer.ts | 2 +- packages/controller/src/routes.ts | 2 +- .../create/templates/plugin-ts/controller.ts | 6 +- packages/host/src/Host.ts | 173 +++++---- packages/lib/src/data/HostDetails.ts | 77 ++++ packages/lib/src/data/InstanceDetails.ts | 108 ++++++ .../lib/src/data/{User.ts => UserDetails.ts} | 74 +--- packages/lib/src/data/index.ts | 6 +- packages/lib/src/data/messages_host.ts | 42 +- packages/lib/src/data/messages_instance.ts | 82 +--- packages/lib/src/data/messages_user.ts | 14 +- packages/lib/src/datastore.ts | 45 ++- packages/lib/src/link/link.ts | 6 +- packages/lib/src/subscriptions.ts | 4 +- .../web_ui/src/components/PluginExtra.tsx | 2 +- packages/web_ui/src/components/UsersTable.tsx | 42 +- packages/web_ui/src/model/roles.ts | 2 +- packages/web_ui/src/model/user.tsx | 26 +- packages/web_ui/src/util/websocket.ts | 2 +- plugins/inventory_sync/controller.ts | 4 +- plugins/player_auth/controller.ts | 4 +- plugins/player_auth/test/plugin.js | 8 +- test/common.js | 8 +- test/controller/Controller.js | 185 ++++++--- test/controller/ControllerUser.js | 30 +- test/controller/HostRecord.test.js | 204 ++++++++++ test/controller/InstanceInfo.js | 36 -- test/controller/InstanceManager.test.js | 358 ++++++++++++++++++ test/controller/InstanceRecord.test.js | 197 ++++++++++ test/controller/User.test.js | 156 ++++++++ test/controller/UserManager.js | 121 ------ test/controller/UserManager.test.js | 172 +++++++++ test/controller/UserRecord.test.js | 148 ++++++++ test/controller/routes.js | 22 +- test/host.js | 4 +- test/integration/clusterio.js | 61 ++- test/integration/routing.js | 11 +- test/integration/server.js | 17 +- test/lib/data/HostDetails.test.js | 145 +++++++ test/lib/data/InstanceDetails.test.js | 153 ++++++++ test/lib/data/User.js | 71 ---- test/lib/data/UserDetails.test.js | 267 +++++++++++++ test/lib/datastore.js | 30 ++ test/messages/controller.test.js | 4 +- test/messages/mod.test.js | 4 +- test/messages/plugin.test.js | 4 +- test/mock.js | 23 +- 60 files changed, 3371 insertions(+), 1227 deletions(-) delete mode 100644 packages/controller/src/ControllerUser.ts rename packages/controller/src/{HostInfo.ts => HostRecord.ts} (57%) create mode 100644 packages/controller/src/InstanceManager.ts rename packages/controller/src/{InstanceInfo.ts => InstanceRecord.ts} (59%) create mode 100644 packages/controller/src/User.ts create mode 100644 packages/controller/src/UserRecord.ts create mode 100644 packages/lib/src/data/HostDetails.ts create mode 100644 packages/lib/src/data/InstanceDetails.ts rename packages/lib/src/data/{User.ts => UserDetails.ts} (69%) create mode 100644 test/controller/HostRecord.test.js delete mode 100644 test/controller/InstanceInfo.js create mode 100644 test/controller/InstanceManager.test.js create mode 100644 test/controller/InstanceRecord.test.js create mode 100644 test/controller/User.test.js delete mode 100644 test/controller/UserManager.js create mode 100644 test/controller/UserManager.test.js create mode 100644 test/controller/UserRecord.test.js create mode 100644 test/lib/data/HostDetails.test.js create mode 100644 test/lib/data/InstanceDetails.test.js delete mode 100644 test/lib/data/User.js create mode 100644 test/lib/data/UserDetails.test.js diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index bea80396..85b20875 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -32,6 +32,7 @@ const { ConsoleTransport, levels, logger } = lib; import Controller from "./src/Controller"; import UserManager from "./src/UserManager"; +import UserRecord from "./src/UserRecord"; import { version } from "./package.json"; // globals @@ -133,13 +134,22 @@ async function handleBootstrapCommand( let subCommand = args._[1]; const databaseDirectory = controllerConfig.get("controller.database_directory"); + await UserManager.attemptMigrateLegacyUsersFile( + path.join(databaseDirectory, "users.json"), + path.join(databaseDirectory, "roles.json"), + ); + const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "roles.json"), lib.Role.fromJSON.bind(lib.Role), ).bootstrap()); - const userManager = new UserManager(controllerConfig, roles); - await userManager.load(path.join(databaseDirectory, "users.json")); + const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "users.json"), + UserRecord.fromJSON.bind(UserRecord), + ).bootstrap()); + + const userManager = new UserManager(users, roles, controllerConfig); if (subCommand === "create-admin") { await controllerConfigLock.acquire(); // Also needed to write to the database files @@ -150,14 +160,12 @@ async function handleBootstrapCommand( return; } - let admin = userManager.getByName(args.name); - if (!admin) { - admin = userManager.createUser(args.name); - } + const user = userManager.getOrCreateUser(args.name); + user.roleIds.add(lib.Role.DefaultAdminRoleId); + user.isAdmin = true; + user.saveRecord(); - admin.isAdmin = true; - admin.roleIds.add(lib.Role.DefaultAdminRoleId); - await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json")); + await userManager.records.save(); await controllerConfigLock.release(); } else if (subCommand === "generate-user-token") { @@ -430,7 +438,7 @@ async function startup() { ); // Refuse to start if there are no users loaded - if (args.checkUserCount && controller.userManager.users.size === 0) { + if (args.checkUserCount && controller.users.records.size === 0) { logger.fatal( "Cannot start controller, no users loaded.\n" + "Try `npx clusteriocontroller bootstrap create-admin `"); diff --git a/packages/controller/index.ts b/packages/controller/index.ts index 6819b7cc..4e7b2b63 100644 --- a/packages/controller/index.ts +++ b/packages/controller/index.ts @@ -1,13 +1,15 @@ import { bootstrap } from "./controller"; export { default as Controller } from "./src/Controller"; -export { default as ControllerUser } from "./src/ControllerUser"; export { default as ControllerRouter } from "./src/ControllerRouter"; export { default as ControlConnection } from "./src/ControlConnection"; export { default as HostConnection } from "./src/HostConnection"; -export { default as InstanceInfo } from "./src/InstanceInfo"; -export { default as HostInfo } from "./src/HostInfo"; +export { default as InstanceRecord } from "./src/InstanceRecord"; +export { default as InstanceManager } from "./src/InstanceManager"; +export { default as HostRecord } from "./src/HostRecord"; export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin"; export { default as UserManager } from "./src/UserManager"; +export { default as UserRecord } from "./src/UserRecord"; +export { default as User } from "./src/User"; export { default as WsServer } from "./src/WsServer"; if (module === require.main) { diff --git a/packages/controller/src/BaseControllerPlugin.ts b/packages/controller/src/BaseControllerPlugin.ts index 92e019db..7b052b42 100644 --- a/packages/controller/src/BaseControllerPlugin.ts +++ b/packages/controller/src/BaseControllerPlugin.ts @@ -3,7 +3,7 @@ import type { ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo, } from "@clusterio/lib"; import type Controller from "./Controller"; -import type InstanceInfo from "./InstanceInfo"; +import type InstanceRecord from "./InstanceRecord"; import type ControlConnection from "./ControlConnection"; import type HostConnection from "./HostConnection"; @@ -91,7 +91,7 @@ export default class BaseControllerPlugin { * The instance that changed. * @param prev - the previous status of the instance. */ - async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { } + async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { } /** * Called when the value of a controller config field changed. @@ -117,7 +117,7 @@ export default class BaseControllerPlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { } + async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { } /** * Called before collecting Prometheus metrics @@ -261,7 +261,7 @@ export default class BaseControllerPlugin { * The instance it occured on. * @param event - Information about the event. */ - async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { } + async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { } /** * Broadcast event to all connected hosts diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 260963dd..673a9277 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -6,7 +6,8 @@ import * as lib from "@clusterio/lib"; const { logFilter, logger } = lib; import BaseConnection from "./BaseConnection"; -import ControllerUser from "./ControllerUser"; +import User from "./User"; +import UserRecord from "./UserRecord"; import * as routes from "./routes"; import Controller from "./Controller"; @@ -40,7 +41,7 @@ export default class ControlConnection extends BaseConnection { registerData: { version: string }, connector: WsServerConnector, controller: Controller, - public user: ControllerUser, // The user making this connection. + public user: User, // The user making this connection. public id: number ) { super(connector, controller); @@ -250,7 +251,7 @@ export default class ControlConnection extends BaseConnection { } async handleInstanceDetailsGetRequest(request: lib.InstanceDetailsGetRequest) { - return this._controller.getRequestInstance(request.instanceId).toInstanceDetails(); + return this._controller.instances.getForRequest(request.instanceId).toInstanceDetails(); } async handleInstanceDetailsListRequest() { @@ -268,20 +269,20 @@ export default class ControlConnection extends BaseConnection { instanceConfig.set("instance.assigned_host", null); // New instances are unassigned } instanceConfig.update(request.config, false, "control"); - await this._controller.instanceCreate(instanceConfig); + await this._controller.instances.createInstance(instanceConfig); } async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) { - await this._controller.instanceDelete(request.instanceId); + await this._controller.instances.deleteInstance(request.instanceId); } async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) { - let instance = this._controller.getRequestInstance(request.instanceId); + let instance = this._controller.instances.getForRequest(request.instanceId); return instance.config.toRemote("control"); } async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) { - let instance = this._controller.getRequestInstance(request.instanceId); + let instance = this._controller.instances.getForRequest(request.instanceId); if (request.field === "instance.assigned_host") { throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface"); } @@ -296,14 +297,14 @@ export default class ControlConnection extends BaseConnection { } async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) { - let instance = this._controller.getRequestInstance(request.instanceId); + let instance = this._controller.instances.getForRequest(request.instanceId); let { field, prop, value } = request; instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control"); await this._controller.instanceConfigUpdated(instance); } async handleInstanceAssignRequest(request: lib.InstanceAssignRequest) { - await this._controller.instanceAssign(request.instanceId, request.hostId); + await this._controller.instances.assignInstance(request.instanceId, request.hostId ?? null); } async handleInstanceSaveDetailsListRequest() { @@ -337,8 +338,8 @@ export default class ControlConnection extends BaseConnection { if (request.sourceInstanceId === request.targetInstanceId) { throw new lib.RequestError("Source and target instance may not be the same"); } - let sourceInstance = this._controller.getRequestInstance(request.sourceInstanceId); - let targetInstance = this._controller.getRequestInstance(request.targetInstanceId); + let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId); + let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId); let sourceHostId = sourceInstance.config.get("instance.assigned_host"); let targetHostId = targetInstance.config.get("instance.assigned_host"); if (sourceHostId === null) { @@ -870,35 +871,33 @@ export default class ControlConnection extends BaseConnection { } this._controller.roles.delete(role); - this._controller.userManager.dirty = true; - for (let user of this._controller.userManager.users.values()) { - if (user.roleIds.delete(id)) { + for (const user of this._controller.users.values()) { + if (user.removeRole(id)) { this._controller.userPermissionsUpdated(user); } } } - async handleUserGetRequest(request: lib.UserGetRequest): Promise { + async handleUserGetRequest(request: lib.UserGetRequest): Promise { let name = request.name; - let user = this._controller.userManager.getByName(name); + let user = this._controller.users.getByName(name); if (!user) { throw new lib.RequestError(`User ${name} does not exist`); } - return user; + return user.toUserDetails(); } - async handleUserListRequest(): Promise { - return [...this._controller.userManager.users.values()]; + async handleUserListRequest(): Promise { + return [...this._controller.users.records.values()].map(user => user.toUserDetails()); } async handleUserCreateRequest(request: lib.UserCreateRequest) { - let user = this._controller.userManager.createUser(request.name); - this._controller.usersUpdated([user]); + this._controller.users.createUser(request.name); } async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) { - let user = this._controller.userManager.getByName(request.name); + let user = this._controller.users.getByName(request.name); if (!user) { throw new lib.RequestError(`User '${request.name}' does not exist`); } @@ -912,11 +911,10 @@ export default class ControlConnection extends BaseConnection { controlConnection.connector.terminate(); } } - this._controller.usersUpdated([user]); } async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) { - let user = this._controller.userManager.getByName(request.name); + let user = this._controller.users.getByName(request.name); if (!user) { throw new lib.RequestError(`User '${request.name}' does not exist`); } @@ -927,35 +925,33 @@ export default class ControlConnection extends BaseConnection { } } - user.roleIds = new Set(request.roles); + user.set("roleIds", new Set(request.roles)); this._controller.userPermissionsUpdated(user); - this._controller.usersUpdated([user]); } async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) { let { name, create, admin } = request; - let user = this._controller.userManager.getByName(name); + let user = this._controller.users.getByName(name); if (!user) { if (create) { this.user.checkPermission("core.user.create"); - user = this._controller.userManager.createUser(name); + user = this._controller.users.createUser(name); } else { throw new lib.RequestError(`User '${name}' does not exist`); } } - user.isAdmin = admin; - this._controller.usersUpdated([user]); + user.set("isAdmin", admin); this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, admin)); } async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) { let { name, create, banned, reason } = request; - let user = this._controller.userManager.getByName(name); + let user = this._controller.users.getByNameMutable(name); if (!user) { if (create) { this.user.checkPermission("core.user.create"); - user = this._controller.userManager.createUser(name); + user = this._controller.users.createUser(name); } else { throw new lib.RequestError(`User '${name}' does not exist`); } @@ -963,37 +959,34 @@ export default class ControlConnection extends BaseConnection { user.isBanned = banned; user.banReason = reason; - this._controller.usersUpdated([user]); + user.saveRecord(); this._controller.sendTo("allInstances", new lib.InstanceBanlistUpdateEvent(name, banned, reason)); } async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) { let { name, create, whitelisted } = request; - let user = this._controller.userManager.getByName(name); + let user = this._controller.users.getByName(name); if (!user) { if (create) { this.user.checkPermission("core.user.create"); - user = this._controller.userManager.createUser(name); + user = this._controller.users.createUser(name); } else { throw new lib.RequestError(`User '${name}' does not exist`); } } - user.isWhitelisted = whitelisted; - this._controller.usersUpdated([user]); + user.set("isWhitelisted", whitelisted); this._controller.sendTo("allInstances", new lib.InstanceWhitelistUpdateEvent(name, whitelisted)); } async handleUserDeleteRequest(request: lib.UserDeleteRequest) { let name = request.name; - let user = this._controller.userManager.getByName(name); + let user = this._controller.users.getByName(name); if (!user) { throw new lib.RequestError(`User '${name}' does not exist`); } - user.isDeleted = true; - this._controller.userManager.users.delete(name); - this._controller.usersUpdated([user]); + this._controller.users.deleteUser(user); if (user.isAdmin) { this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false)); @@ -1006,14 +999,15 @@ export default class ControlConnection extends BaseConnection { } } - async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map) { + /** This does not do a full restore, rather it demotes / unbanns / unwhitelistes users */ + async handleUserBulkRestoreRequest(request: lib.UserBulkImportRequest, updated: Map) { if (request.importType === "users") { // Update all fields for all users const users = request.users as Extract; const admin = new Set(users.filter(u => u.is_admin).map(u => u.username.toLowerCase())); const banned = new Set(users.filter(u => u.is_banned).map(u => u.username.toLowerCase())); const whitelist = new Set(users.filter(u => u.is_whitelisted).map(u => u.username.toLowerCase())); - for (const user of this._controller.userManager.users.values()) { + for (const user of this._controller.users.valuesMutable()) { if (user.isAdmin && !admin.has(user.name)) { user.isAdmin = false; updated.set(user.id, user); @@ -1034,7 +1028,7 @@ export default class ControlConnection extends BaseConnection { const expected = new Set( users.map(u => (typeof u === "string" ? u.toLowerCase() : u.username.toLowerCase())) ); - for (const user of this._controller.userManager.users.values()) { + for (const user of this._controller.users.valuesMutable()) { if (user.isBanned && !expected.has(user.name)) { user.isBanned = false; user.banReason = ""; @@ -1046,7 +1040,7 @@ export default class ControlConnection extends BaseConnection { const users = request.users as string[]; const expected = new Set(users.map(u => u.toLowerCase())); const prop = request.importType === "admins" ? "isAdmin" : "isWhitelisted"; - for (const user of this._controller.userManager.users.values()) { + for (const user of this._controller.users.valuesMutable()) { if (user[prop] && !expected.has(user.name)) { user[prop] = false; updated.set(user.id, user); @@ -1055,11 +1049,9 @@ export default class ControlConnection extends BaseConnection { } } - // A large number of small if statements are unavoidable when reducing the number of updates sent - async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) { let backup: undefined | Awaited>; - const updated = new Map(); + const updated = new Map(); if (request.restore) { // Unban / Demote / Unwhitelist players not on the list backup = await this.handleUserBulkExportRequest(new lib.UserBulkExportRequest(request.importType)); @@ -1067,11 +1059,12 @@ export default class ControlConnection extends BaseConnection { } // Will get a user or attempt to create one + // UserManager.getOrCreateUser not used due to inclusion of checkPermission const getUserOrCreate = (username: string) => { - const user = this._controller.userManager.getByName(username); - if (user) { return user; } + const user = this._controller.users.getByNameMutable(username); + if (user) { return updated.get(user.id) ?? user; } this.user.checkPermission("core.user.create"); - return this._controller.userManager.createUser(username); + return this._controller.users.createUser(username); }; // Merge the imported data with existing data, this is strictly additive @@ -1129,14 +1122,14 @@ export default class ControlConnection extends BaseConnection { // Send the necessary update events const updatedUsers = [...updated.values()]; - this._controller.usersUpdated(updatedUsers); + this._controller.users.records.setMany(updatedUsers); // Resync the host user lists const adminlist: Set = new Set(); const banlist: Map = new Map(); const whitelist: Set = new Set(); - for (let user of this._controller.userManager.users.values()) { + for (let user of this._controller.users.values()) { if (user.isAdmin) { adminlist.add(user.id); } @@ -1160,7 +1153,7 @@ export default class ControlConnection extends BaseConnection { if (request.exportType === "users") { // Send a full user export const usersToSend = [] as lib.ClusterioUserExport["users"]; - for (const user of this._controller.userManager.users.values()) { + for (const user of this._controller.users.values()) { let send = false; const userToSend = { username: user.name } as typeof usersToSend[0]; if (user.isBanned) { @@ -1186,7 +1179,7 @@ export default class ControlConnection extends BaseConnection { } else if (request.exportType === "bans") { // Bans have extra logic to handle ban reason const usersToSend = [] as Array; - for (const user of this._controller.userManager.users.values()) { + for (const user of this._controller.users.values()) { if (user.isBanned) { if (user.banReason && user.banReason !== "") { usersToSend.push({ username: user.name, reason: user.banReason }); @@ -1201,7 +1194,7 @@ export default class ControlConnection extends BaseConnection { // This is a whitelist or admins export const usersToSend = [] as string[]; const prop = request.exportType === "admins" ? "isAdmin" : "isWhitelisted"; - for (const user of this._controller.userManager.users.values()) { + for (const user of this._controller.users.values()) { if (user[prop]) { usersToSend.push(user.name); } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 864f5c13..7ad03e2c 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -18,16 +18,17 @@ import * as lib from "@clusterio/lib"; const { logger, Summary, Gauge } = lib; import HttpCloser from "./HttpCloser"; -import InstanceInfo from "./InstanceInfo"; +import InstanceRecord from "./InstanceRecord"; import * as metrics from "./metrics"; import * as routes from "./routes"; -import ControllerUser from "./ControllerUser"; +import User from "./User"; +import UserRecord from "./UserRecord"; import UserManager from "./UserManager"; import WsServer from "./WsServer"; -import HostConnection from "./HostConnection"; -import HostInfo from "./HostInfo"; +import HostRecord from "./HostRecord"; import BaseControllerPlugin from "./BaseControllerPlugin"; import ControllerRouter from "./ControllerRouter"; +import InstanceManager from "./InstanceManager"; const endpointDurationSummary = new Summary( "clusterio_controller_http_endpoint_duration_seconds", @@ -79,6 +80,10 @@ export default class Controller { /** Event subscription controller */ subscriptions = new lib.SubscriptionController(); + /** User manager for the cluster, also contains access to the user records */ + users: UserManager; + /** Instance manager for the cluster, also contains access to the user records */ + instances: InstanceManager; // Possible states are new, starting, running, stopping, stopped private _state: string = "new"; @@ -113,6 +118,12 @@ export default class Controller { let databaseDirectory = config.get("controller.database_directory"); await fs.ensureDir(databaseDirectory); + // Migrate combined user and role file from 2.0.0-alpha.22 + await UserManager.attemptMigrateLegacyUsersFile( + path.join(databaseDirectory, "users.json"), + path.join(databaseDirectory, "roles.json"), + ); + const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "systems.json"), lib.SystemInfo.fromJSON.bind(lib.SystemInfo), @@ -121,13 +132,13 @@ export default class Controller { const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "hosts.json"), - HostInfo.fromJSON.bind(HostInfo), + HostRecord.fromJSON.bind(HostRecord), this.migrateHosts, this.finaliseHosts, ).bootstrap()); const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "instances.json"), - json => InstanceInfo.fromJSON(json, "controller"), + InstanceRecord.fromJSON.bind(InstanceRecord), this.migrateInstances, this.finaliseInstances, ).bootstrap()); @@ -148,8 +159,11 @@ export default class Controller { this.migrateRoles, ).bootstrap()); - const userManager = new UserManager(config, roles); - await userManager.load(path.join(databaseDirectory, "users.json")); + const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "users.json"), + UserRecord.fromJSON.bind(UserRecord), + undefined, this.finaliseUsers, + ).bootstrap()); let modsDirectory = config.get("controller.mods_directory"); await fs.ensureDir(modsDirectory); @@ -172,7 +186,7 @@ export default class Controller { modPacks, modStore, roles, - userManager, + users, ] as const; } @@ -192,10 +206,10 @@ export default class Controller { */ public recoveryMode: boolean = false, public systems = new lib.SubscribableDatastore(), - /** Mapping of host id to host info */ - public hosts = new lib.SubscribableDatastore(), - /** Mapping of instance id to instance info */ - public instances = new lib.SubscribableDatastore(), + /** Mapping of host id to host record */ + public hosts = new lib.SubscribableDatastore(), + /** Mapping of instance id to instance record */ + instances = new lib.SubscribableDatastore(), /** Mapping of save id to save details */ public saves = new lib.SubscribableDatastore(), /** Mapping of mod pack id to mod pack */ @@ -204,8 +218,8 @@ export default class Controller { public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()), /** Mapping of mod pack id to mod pack */ public roles = new lib.SubscribableDatastore(), - /** User and roles manager for the cluster */ - public userManager = new UserManager(config, roles), + /** Mapping of user id to user record */ + users = new lib.SubscribableDatastore(), ) { this.clusterLogger = clusterLogger; this.pluginInfos = pluginInfos; @@ -218,6 +232,9 @@ export default class Controller { this.trustedProxies = this.parseTrustedProxies(); this.wsServer = new WsServer(this); + this.users = new UserManager(users, roles, config); + this.instances = new InstanceManager(instances, this); + this.modStore.on("change", mod => { this.modsUpdated([mod]); }); @@ -237,10 +254,11 @@ export default class Controller { // Handle updates for datastores this.systems.on("update", this.systemsUpdated.bind(this)); this.hosts.on("update", this.hostsUpdated.bind(this)); - this.instances.on("update", this.instanceDetailsUpdated.bind(this)); + this.instances.records.on("update", this.instanceUpdated.bind(this)); this.saves.on("update", this.savesUpdated.bind(this)); this.modPacks.on("update", this.modPacksUpdated.bind(this)); this.roles.on("update", this.rolesUpdated.bind(this)); + this.users.records.on("update", this.usersUpdated.bind(this)); } async start(args: ControllerArgs) { @@ -305,9 +323,6 @@ export default class Controller { } lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev); }); - for (let instance of this.instances.values()) { - this.addInstanceHooks(instance); - } // Make sure we're actually going to listen on a port let httpPort = this.config.get("controller.http_port"); @@ -643,16 +658,13 @@ export default class Controller { this.config.save(), this.systems.save(), this.hosts.save(), - this.instances.save(), + this.instances.records.save(), this.saves.save(), this.modPacks.save(), this.roles.save(), + this.users.records.save(), ]); - if (this.userManager.dirty) { - await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json")); - } - await lib.invokeHook(this.plugins, "onSaveData"); } @@ -675,7 +687,7 @@ export default class Controller { }); } - static migrateHosts(rawJson: unknown[]): Static[] { + static migrateHosts(rawJson: unknown[]): Static[] { let serialized = rawJson as any; // New format 2.0.0.alpha.19 @@ -686,7 +698,7 @@ export default class Controller { return serialized; } - static finaliseHosts(host: HostInfo): HostInfo { + static finaliseHosts(host: HostRecord): HostRecord { if (host.connected) { host.connected = false; host.updatedAtMs = Date.now(); @@ -694,8 +706,8 @@ export default class Controller { return host; } - static migrateInstances(rawJson: unknown[]): Static[] { - const serialized = rawJson as Static[]; + static migrateInstances(rawJson: unknown[]): Static[] { + const serialized = rawJson as Static[]; return serialized.map(json => { if (!json.config) { // New format 2.0.0.alpha.14 return { config: json as any, status: "running" }; // Use running to force updatedAtMs @@ -704,7 +716,7 @@ export default class Controller { }); } - static finaliseInstances(instance: InstanceInfo): InstanceInfo { + static finaliseInstances(instance: InstanceRecord): InstanceRecord { const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown"; if (instance.status !== status) { instance.status = status; @@ -738,6 +750,20 @@ export default class Controller { }); } + static finaliseUsers(user: UserRecord, users: Map): UserRecord { + const existingUser = users.get(user.id); + if (existingUser) { + // All lowercase ids in 2.0.0.alpha.19 + if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) { + // We assume the user with the lower online time is the duplicate + existingUser.merge(user); + return existingUser; + } + user.merge(existingUser); + } + return user; + } + static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise { let manifest = {}; try { @@ -856,7 +882,7 @@ export default class Controller { ); } - hostsUpdated(hosts: HostInfo[]) { + hostsUpdated(hosts: HostRecord[]) { let updates = hosts.map(host => host.toHostDetails()); this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates)); } @@ -868,75 +894,6 @@ export default class Controller { return hosts.length ? new lib.HostUpdatesEvent(hosts) : null; } - /** - * Get instance by ID for a request - * - * @param instanceId - ID of instance to get. - * @returns Info for the given instance if it exists - * @throws {module:lib.RequestError} if the instance does not exist. - */ - getRequestInstance(instanceId:number): InstanceInfo { - let instance = this.instances.get(instanceId); - if (!instance) { - throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); - } - return instance; - } - - /** - * Create a new instance - * - * Adds common Factorio settings to the provided instance config then - * creates an instance using that config in the cluster. - * - * @example - * let instanceConfig = new lib.InstanceConfig("controller"); - * instanceConfig.set("instance.name", "My instance"); - * let instance = await controller.instanceAssign(instanceConfig); - * await controller.instanceAssign(instance.id, hostId); - * - * @param instanceConfig - - * Config to base newly created instance on. - * @returns The created instance - */ - async instanceCreate(instanceConfig: lib.InstanceConfig): Promise { - let instanceId = instanceConfig.get("instance.id"); - if (this.instances.has(instanceId)) { - throw new lib.RequestError(`Instance with ID ${instanceId} already exists`); - } - - // Add common settings for the Factorio server - let settings = { - "name": `${this.config.get("controller.name")} - ${instanceConfig.get("instance.name")}`, - "description": `Clusterio instance for ${this.config.get("controller.name")}`, - "tags": ["clusterio"], - "max_players": 0, - "visibility": { "public": true, "lan": true }, - "game_password": "", - "require_user_verification": true, - "max_upload_in_kilobytes_per_second": 0, - "max_upload_slots": 5, - "ignore_player_limit_for_returning_players": false, - "allow_commands": "admins-only", - "autosave_interval": 10, - "autosave_slots": 5, - "afk_autokick_interval": 0, - "auto_pause": false, - "only_admins_can_pause_the_game": true, - "autosave_only_on_server": true, - "non_blocking_saving": false, - - ...instanceConfig.get("factorio.settings"), - }; - instanceConfig.set("factorio.settings", settings); - - let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now()); - this.instances.set(instance); - await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance); - this.addInstanceHooks(instance); - return instance; - } - /** * Removes all saves currently stored for the given instance, if any * @param instanceId - Id of Instance to clear saves for. @@ -946,84 +903,6 @@ export default class Controller { this.saves.deleteMany([...this.saves.values()].filter(save => save.instanceId === instanceId)); } - /** - * Change assigned host of an instance - * - * Unassigns instance from existing host if already assigned to one and - * then assigns it to the given host. This is the only supported way of - * changing the instance.assigned_host config entry. - * - * Note: this will not transfer any files or saves stored on the host - * the instance was previously assgined to the new one. - * - * @param instanceId - ID of Instance to assign. - * @param hostId - ID of host to assign instance to. - */ - async instanceAssign(instanceId: number, hostId?: number) { - let instance = this.getRequestInstance(instanceId); - - // Check if target host is connected - let newHostConnection: HostConnection | undefined; - if (hostId !== undefined) { - newHostConnection = this.wsServer.hostConnections.get(hostId); - if (!newHostConnection) { - // The case of the host not getting the assign instance message - // still have to be handled, so it's not a requirement that the - // target host be connected to the controller while doing the - // assignment, but it is IMHO a better user experience if this - // is the case. - throw new lib.RequestError("Target host is not connected to the controller"); - } - } - - // Unassign from currently assigned host if it is connected. - let currentAssignedHost = instance.config.get("instance.assigned_host"); - if (currentAssignedHost !== null && hostId !== currentAssignedHost) { - let oldHostConnection = this.wsServer.hostConnections.get(currentAssignedHost); - if (oldHostConnection && !oldHostConnection.connector.closing) { - await oldHostConnection.send(new lib.InstanceUnassignInternalRequest(instanceId)); - } - } - - // Remove saves recorded from currently assigned host if any - this.clearSavesOfInstance(instanceId); - - // Assign to target - instance.config.set("instance.assigned_host", hostId ?? null); - if (hostId !== undefined && newHostConnection) { - await newHostConnection.send( - new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")) - ); - } else { - instance.status = "unassigned"; - } - this.instances.set(instance); - } - - /** - * Delete an instance - * - * Permanently deletes the instance from its assigned host (if assigned) - * and the controller. This action cannot be undone. - * - * @param instanceId - ID of instance to delete. - */ - async instanceDelete(instanceId: number) { - let instance = this.getRequestInstance(instanceId); - let hostId = instance.config.get("instance.assigned_host"); - if (hostId !== null) { - await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId)); - } - - let prev = instance.status; - this.instances.delete(instance); - - this.clearSavesOfInstance(instanceId); - this.userManager.clearStatsOfInstance(instanceId); - - await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev); - } - /** * Notify the config of an instance updated * @@ -1033,7 +912,7 @@ export default class Controller { * @param instance - * Instance to nodify the config updated. */ - async instanceConfigUpdated(instance: InstanceInfo) { + async instanceConfigUpdated(instance: InstanceRecord) { let hostId = instance.config.get("instance.assigned_host"); if (hostId !== null) { let connection = this.wsServer.hostConnections.get(hostId); @@ -1045,16 +924,7 @@ export default class Controller { } } - addInstanceHooks(instance: InstanceInfo) { - instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => { - instance.updatedAtMs = Date.now(); - this.instances.set(instance); // Trigger update logic - - lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev); - }); - } - - instanceDetailsUpdated(instances: InstanceInfo[]) { + instanceUpdated(instances: InstanceRecord[]) { for (const instance of instances) { if (instance.status === "stopped") { this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id})); @@ -1106,17 +976,12 @@ export default class Controller { return mods.length ? new lib.ModUpdatesEvent(mods) : null; } - usersUpdated(users: ControllerUser[]) { - const now = Date.now(); - for (const user of users) { - user.updatedAtMs = now; - } - this.userManager.dirty = true; + usersUpdated(users: UserRecord[]) { this.subscriptions.broadcast(new lib.UserUpdatesEvent(users)); } async handleUserSubscription(request: lib.SubscriptionRequest) { - const users = [...this.userManager.users.values()].filter( + const users = [...this.users.records.values()].filter( user => user.updatedAtMs > request.lastRequestTimeMs, ); return users.length ? new lib.UserUpdatesEvent(users) : null; @@ -1127,7 +992,7 @@ export default class Controller { * permissions for this user may have changed. * @param user - User permisions updated for. */ - userPermissionsUpdated(user: ControllerUser) { + userPermissionsUpdated(user: Readonly) { for (let controlConnection of this.wsServer.controlConnections.values()) { if (controlConnection.user === user) { controlConnection.send( @@ -1386,7 +1251,7 @@ export default class Controller { } } else if (dst.type === lib.Address.instance) { - let instance = this.getRequestInstance(dst.id); + const instance = this.instances.getForRequest(dst.id); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { throw new lib.RequestError("Instance is not assigned to a host"); @@ -1421,7 +1286,7 @@ export default class Controller { } } else if (dst.type === lib.Address.instance) { - let instance = this.instances.get(dst.id); + const instance = this.instances.get(dst.id); if (!instance) { throw new Error(`Instance with ID ${dst.id} does not exist`); } @@ -1480,7 +1345,7 @@ export default class Controller { } async sendRequestToHostByInstanceId(request: Req & lib.Request) { - let instance = this.getRequestInstance(request.instanceId); + let instance = this.instances.getForRequest(request.instanceId); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { throw new lib.RequestError("Instance is not assigned to a host"); @@ -1493,7 +1358,7 @@ export default class Controller { } sendEventToHostByInstanceId(event: T & lib.Event) { - let instance = this.getRequestInstance(event.instanceId); + let instance = this.instances.getForRequest(event.instanceId); let hostId = instance.config.get("instance.assigned_host"); if (hostId === null) { return; diff --git a/packages/controller/src/ControllerUser.ts b/packages/controller/src/ControllerUser.ts deleted file mode 100644 index 3673ae95..00000000 --- a/packages/controller/src/ControllerUser.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Static, Type } from "@sinclair/typebox"; - -import { - IControllerUser, PermissionError, PlayerStats, - Role, SubscribableDatastore, User, permissions, -} from "@clusterio/lib"; - -export default class ControllerUser extends User implements IControllerUser { - constructor( - private _controllerRoles: SubscribableDatastore, - /** Unix time in seconds the user token must be issued after to be valid. */ - public tokenValidAfter = 0, - ...args: ConstructorParameters - ) { - super(...args); - } - - static jsonSchema = Type.Object({ - ...User.jsonSchema.properties, - token_valid_after: Type.Optional(Type.Number()), - }); - - static fromJSON(json: Static, controllerRoles: SubscribableDatastore) { - const roleIds = new Set(json.roles ?? []); - const instanceStats = new Map( - (json.instance_stats ?? []).map( - ([id, stats]) => [id, PlayerStats.fromJSON(stats)] - ) - ); - const playerStats = User._calculatePlayerStats(instanceStats); - return new this( - controllerRoles, - json.token_valid_after, - json.name, - roleIds, - new Set(json.instances), - json.is_admin, - json.is_banned, - json.is_whitelisted, - json.ban_reason, - json.updated_at_ms, - json.is_deleted, - playerStats, - instanceStats, - ); - } - - toJSON(controller?: boolean): Static { - if (!controller) { - return super.toJSON(); - } - - const json: Static = super.toJSON(); - if (this.tokenValidAfter) { - json.token_valid_after = this.tokenValidAfter; - } - - return json; - } - - get roles(): ReadonlySet> { - return new Set([...this.roleIds].map( - id => this._controllerRoles.get(id) - ).filter( - (r): r is Role => Boolean(r) - )); - } - - /** - * Invalidate current tokens for the user - * - * Sets the tokenValidAfter property to the current time, which causes - * all currently issued tokens for the user to become invalid. - */ - invalidateToken() { - this.tokenValidAfter = Math.floor(Date.now() / 1000); - } - - checkPermission(permission: string) { - if (!permissions.has(permission)) { - throw new Error(`permission ${permission} does not exist`); - } - - for (let roleId of this.roleIds) { - let role = this._controllerRoles.get(roleId); - if (!role) { - continue; - } - if (role.permissions.has("core.admin") || role.permissions.has(permission)) { - return; - } - } - - throw new PermissionError("Permission denied"); - } -} diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 688130dc..66601e20 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -5,8 +5,8 @@ import * as lib from "@clusterio/lib"; const { logger } = lib; import BaseConnection from "./BaseConnection"; -import InstanceInfo from "./InstanceInfo"; -import HostInfo from "./HostInfo"; +import InstanceRecord from "./InstanceRecord"; +import HostRecord from "./HostRecord"; /** @@ -16,7 +16,7 @@ import HostInfo from "./HostInfo"; * @alias module:controller/src/HostConnection */ export default class HostConnection extends BaseConnection { - private info: HostInfo; + private info: HostRecord; plugins: Map; constructor( @@ -30,7 +30,7 @@ export default class HostConnection extends BaseConnection { this.plugins = new Map(Object.entries(registerData.plugins)); const previousHostInfo = this._controller.hosts.get(registerData.id); - this.info = new HostInfo( + this.info = new HostRecord( registerData.id, previousHostInfo?.name ?? "", registerData.version, @@ -57,7 +57,7 @@ export default class HostConnection extends BaseConnection { this.connector.on("close", () => { // Update status to unknown for instances on this host. - const instances: InstanceInfo[] = []; + const instances: InstanceRecord[] = []; for (let instance of this._controller.instances.valuesMutable()) { if (instance.config.get("instance.assigned_host") !== this.id) { continue; @@ -68,7 +68,7 @@ export default class HostConnection extends BaseConnection { instance.status = "unknown"; lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } - this._controller.instances.setMany(instances); + this._controller.instances.records.setMany(instances); this.info.connected = false; this._controller.hosts.set(this.info); @@ -215,7 +215,7 @@ export default class HostConnection extends BaseConnection { instance.gamePort = request.gamePort; instance.factorioVersion = request.factorioVersion; - this._controller.instances.set(instance); + this._controller.instances.records.set(instance); logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`); await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev); } @@ -231,7 +231,7 @@ export default class HostConnection extends BaseConnection { } // Assign instances the host has but controller does not - const instanceUpdates: InstanceInfo[] = []; + const instanceUpdates: InstanceRecord[] = []; for (let instanceData of request.instances) { const instanceConfig = new lib.InstanceConfig("controller"); instanceConfig.update(instanceData.config, false, "host"); @@ -265,32 +265,30 @@ export default class HostConnection extends BaseConnection { } instanceConfig.set("instance.assigned_host", this.id); - let newInstance = new InstanceInfo( - instanceConfig, - instanceData.status, - instanceData.gamePort, - instanceData.factorioVersion, - 0, - Date.now(), - ); + const newInstance = await this._controller.instances.createInstance(instanceConfig, true); + newInstance.factorioVersion = instanceData.factorioVersion; + newInstance.gamePort = instanceData.gamePort; + newInstance.status = instanceData.status; instanceUpdates.push(newInstance); - this._controller.addInstanceHooks(newInstance); - await this.send( - new lib.InstanceAssignInternalRequest( - instanceConfig.get("instance.id"), instanceConfig.toRemote("host") - ) - ); - await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance); } - this._controller.instances.setMany(instanceUpdates); + // We suppress the changes made by createInstance so we must manually make them here + this._controller.instances.records.setMany(instanceUpdates); + await Promise.all(instanceUpdates.flatMap(newInstance => [ + lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance), + this.send( + new lib.InstanceAssignInternalRequest( + newInstance.config.get("instance.id"), newInstance.config.toRemote("host") + ) + ), + ])); // Push lists to make sure they are in sync. let adminlist: Set = new Set(); let banlist: Map = new Map(); let whitelist: Set = new Set(); - for (let user of this._controller.userManager.users.values()) { + for (let user of this._controller.users.values()) { if (user.isAdmin) { adminlist.add(user.id); } @@ -339,21 +337,18 @@ export default class HostConnection extends BaseConnection { async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) { let instanceId = src.id; - let user = this._controller.userManager.getByName(event.name); - if (!user) { - user = this._controller.userManager.createUser(event.name); - } + const user = this._controller.users.getOrCreateUser(event.name); if (event.type === "join") { - this._controller.userManager.notifyJoin(user, instanceId); + user.notifyJoin(instanceId); } else if (event.type === "leave") { - this._controller.userManager.notifyLeave(user, instanceId); + user.notifyLeave(instanceId); } user.instanceStats.set(instanceId, event.stats); user.recalculatePlayerStats(); - this._controller.usersUpdated([user]); + user.saveRecord(); let instance = this._controller.instances.get(instanceId)!; await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { @@ -373,13 +368,9 @@ export default class HostConnection extends BaseConnection { return; } - let user = this._controller.userManager.getByName(event.name); - if (!user) { - user = this._controller.userManager.createUser(event.name); - } + const user = this._controller.users.getOrCreateUser(event.name); + user.set("isAdmin", event.admin); - user.isAdmin = event.admin; - this._controller.usersUpdated([user]); const instance = this._controller.instances.get(src.id)!; await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"], @@ -393,14 +384,11 @@ export default class HostConnection extends BaseConnection { return; } - let user = this._controller.userManager.getByName(event.name); - if (!user) { - user = this._controller.userManager.createUser(event.name); - } - + const user = this._controller.users.getOrCreateUser(event.name); user.isBanned = event.banned; user.banReason = event.reason; - this._controller.usersUpdated([user]); + user.saveRecord(); + const instance = this._controller.instances.get(src.id)!; await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"], @@ -415,13 +403,9 @@ export default class HostConnection extends BaseConnection { return; } - let user = this._controller.userManager.getByName(event.name); - if (!user) { - user = this._controller.userManager.createUser(event.name); - } + const user = this._controller.users.getOrCreateUser(event.name); + user.set("isWhitelisted", event.whitelisted); - user.isWhitelisted = event.whitelisted; - this._controller.usersUpdated([user]); const instance = this._controller.instances.get(src.id)!; await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, { type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"], diff --git a/packages/controller/src/HostInfo.ts b/packages/controller/src/HostRecord.ts similarity index 57% rename from packages/controller/src/HostInfo.ts rename to packages/controller/src/HostRecord.ts index 5d876e86..fa65c701 100644 --- a/packages/controller/src/HostInfo.ts +++ b/packages/controller/src/HostRecord.ts @@ -1,10 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; import { HostDetails } from "@clusterio/lib"; -/** - * Info about a host known to the controller. - */ -export default class HostInfo { +/** Underlying data class for hosts on the controller */ +export default class HostRecord { constructor( /** Id of this host */ public id: number, @@ -33,12 +31,12 @@ export default class HostInfo { "name": Type.String(), "version": Type.String(), "plugins": Type.Record(Type.String(), Type.String()), - "connected": Type.Boolean(), - "remote_address": Type.String(), - "public_address": Type.String(), - "token_valid_after": Type.Number(), - "updated_at_ms": Type.Number(), - "is_deleted": Type.Boolean(), + "connected": Type.Optional(Type.Boolean()), + "remote_address": Type.Optional(Type.String()), + "public_address": Type.Optional(Type.String()), + "token_valid_after": Type.Optional(Type.Number()), + "updated_at_ms": Type.Optional(Type.Number()), + "is_deleted": Type.Optional(Type.Boolean()), }); static fromJSON(json: Static) { @@ -56,19 +54,50 @@ export default class HostInfo { ); } - toJSON(): Static { - return { + toJSON() { + const json = { id: this.id, name: this.name, version: this.version, plugins: Object.fromEntries(this.plugins), - connected: this.connected, - remote_address: this.remoteAddress, - public_address: this.publicAddress, - token_valid_after: this.tokenValidAfter, - updated_at_ms: this.updatedAtMs, - is_deleted: this.isDeleted, - }; + } as Static; + + if (this.connected !== false) { + json.connected = this.connected; + } + if (this.remoteAddress !== "") { + json.remote_address = this.remoteAddress; + } + if (this.publicAddress !== "") { + json.public_address = this.publicAddress; + } + if (this.tokenValidAfter !== 0) { + json.token_valid_after = this.tokenValidAfter; + } + if (this.updatedAtMs !== 0) { + json.updated_at_ms = this.updatedAtMs; + } + if (this.isDeleted !== false) { + json.is_deleted = this.isDeleted; + } + + return json; + } + + + static fromHostDetails(details: HostDetails, plugins: Map) { + return new HostRecord( + details.id, + details.name, + details.version, + plugins, + details.connected, + details.remoteAddress, + details.publicAddress, + details.tokenValidAfter, + details.updatedAtMs, + details.isDeleted, + ); } toHostDetails() { @@ -85,4 +114,3 @@ export default class HostInfo { ); } } - diff --git a/packages/controller/src/InstanceManager.ts b/packages/controller/src/InstanceManager.ts new file mode 100644 index 00000000..e1cf1941 --- /dev/null +++ b/packages/controller/src/InstanceManager.ts @@ -0,0 +1,274 @@ +import * as lib from "@clusterio/lib"; + +import type Controller from "./Controller"; +import type HostConnection from "./HostConnection"; +import InstanceRecord from "./InstanceRecord"; + +export default class InstanceManager { + private static readonly DefaultFactorioSettings = { + tags: ["clusterio"], + max_players: 0, + visibility: { public: true, lan: true }, + game_password: "", + require_user_verification: true, + max_upload_in_kilobytes_per_second: 0, + max_upload_slots: 5, + ignore_player_limit_for_returning_players: false, + allow_commands: "admins-only", + autosave_interval: 10, + autosave_slots: 5, + afk_autokick_interval: 0, + auto_pause: false, + only_admins_can_pause_the_game: true, + autosave_only_on_server: true, + non_blocking_saving: false, + } as const; + + constructor( + public readonly records: lib.SubscribableDatastore, + private readonly _controller: Controller, + ) { + for (const instance of records.values()) { + this.addInstanceHooks(instance); + } + } + + /* ------------------------------------------------------------------------- */ + /* Basic Accessors */ + /* ------------------------------------------------------------------------- */ + + has(id: InstanceRecord["id"]): boolean { + return this.records.has(id); + } + + getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined { + return this.records.get(id); + } + + get(id: InstanceRecord["id"]): Readonly | undefined { + return this.getMutable(id); + } + + valuesMutable(): IterableIterator { + return this.records.values(); + } + + values(): IterableIterator> { + return this.valuesMutable(); + } + + /** + * Get instance by ID for a request. + * + * Convenience wrapper around {@link getMutable} that throws + * if the instance does not exist. + * + * @param instanceId - ID of instance to get. + * @returns The instance record. + * @throws {module:lib.RequestError} if the instance does not exist. + */ + getForRequest(instanceId: number): InstanceRecord { + const instance = this.records.get(instanceId); + if (!instance) { + throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); + } + return instance; + } + + /* ------------------------------------------------------------------------- */ + /* Internal Helpers */ + /* ------------------------------------------------------------------------- */ + + private async _notifyStatusChanged( + instance: InstanceRecord, + prev?: lib.InstanceStatus, + ): Promise { + await lib.invokeHook( + this._controller.plugins, + "onInstanceStatusChanged", + instance, + prev, + ); + } + + private async _notifyConfigFieldChanged( + instance: InstanceRecord, + field: string, + curr: unknown, + prev: unknown, + ): Promise { + await lib.invokeHook( + this._controller.plugins, + "onInstanceConfigFieldChanged", + instance, + field, + curr, + prev, + ); + } + + /** + * Attach change listeners to an instance record. + * + * Hooks into configuration field changes and ensures the datastore + * is updated and plugins are notified when a config field changes. + * + * @param instance - The instance record to attach hooks to. + * @internal + */ + addInstanceHooks(instance: InstanceRecord): void { + instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => { + this.records.set(instance); + this._notifyConfigFieldChanged(instance, field, curr, prev); + }); + } + + /* ------------------------------------------------------------------------- */ + /* Lifecycle */ + /* ------------------------------------------------------------------------- */ + + /** + * Create a new instance. + * + * @example + * const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" }); + * const instance = await controller.instances.createInstance(config); + * + * @param instanceConfig - Config to base the newly created instance on. + * @param _suppressChanges - When true, side effects are suppressed, caller takes ownership. + * @returns The created instance record. + * @throws {module:lib.RequestError} if an instance with the same ID already exists. + */ + async createInstance( + instanceConfig: lib.InstanceConfig, + _suppressChanges?: boolean, + ): Promise { + const instanceId = instanceConfig.get("instance.id"); + if (this.records.has(instanceId)) { + throw new lib.RequestError(`Instance with ID ${instanceId} already exists`); + } + + const controllerName = this._controller.config.get("controller.name"); + instanceConfig.set("factorio.settings", { + name: `${controllerName} - ${instanceConfig.get("instance.name")}`, + description: `Clusterio instance for ${controllerName}`, + ...InstanceManager.DefaultFactorioSettings, + ...instanceConfig.get("factorio.settings"), + }); + + const instance = new InstanceRecord(instanceConfig, "unassigned"); + this.addInstanceHooks(instance); + + if (!_suppressChanges) { + this.records.set(instance); + await this._notifyStatusChanged(instance); + } + + return instance; + } + + /** + * Change the assigned host of an instance. + * + * Unassigns the instance from the currently assigned host (if connected) + * and assigns it to the provided host. + * + * This is the only supported way of modifying the + * `instance.assigned_host` configuration entry. + * + * Note: + * - This does NOT transfer any files or saves between hosts. + * - If the instance is already assigned to the specified host, no action is taken. + * + * @param instanceId - ID of instance to assign. + * @param hostId - ID of host to assign the instance to. If undefined, + * the instance will be unassigned. + * + * @throws {module:lib.RequestError} if the instance does not exist. + * @throws {module:lib.RequestError} if the target host is not connected. + */ + async assignInstance( + instanceId: number, + hostId: number | null, + ): Promise { + const instance = this.getForRequest(instanceId); + const hostConnections = this._controller.wsServer.hostConnections; + + const currentAssignedHost = instance.config.get("instance.assigned_host"); + if (currentAssignedHost === hostId) { + return; + } + + let newHostConnection: HostConnection | undefined; + if (hostId !== null) { + newHostConnection = hostConnections.get(hostId); + if (!newHostConnection) { + throw new lib.RequestError("Target host is not connected to the controller"); + } + } + + if (currentAssignedHost !== null) { + const oldHostConnection = hostConnections.get(currentAssignedHost); + if (oldHostConnection && !oldHostConnection.connector.closing) { + await oldHostConnection.send( + new lib.InstanceUnassignInternalRequest(instanceId), + ); + } + } + + this._controller.clearSavesOfInstance(instanceId); + instance.config.set("instance.assigned_host", hostId); + + if (newHostConnection) { + await newHostConnection.send( + new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")), + ); + } else { + instance.status = "unassigned"; + } + + this.records.set(instance); + } + + /** + * Unassign an instance from its current host. + * + * Convenience wrapper around {@link assignInstance} that removes + * the assigned host. + * + * @param instanceId - ID of instance to unassign. + */ + async unassignInstance(instanceId: number): Promise { + return this.assignInstance(instanceId, null); + } + + /** + * Delete an instance permanently. + * + * Sends a delete request to the assigned host (if any), + * removes the instance from the datastore, + * clears associated saves and user statistics. + * + * @param instanceId - ID of instance to delete. + * + * @throws {module:lib.RequestError} if the instance does not exist. + */ + async deleteInstance(instanceId: number): Promise { + const instance = this.getForRequest(instanceId); + const hostId = instance.config.get("instance.assigned_host"); + + if (hostId !== null) { + await this._controller.sendTo( + { hostId }, + new lib.InstanceDeleteInternalRequest(instanceId), + ); + } + + const prevStatus = instance.status; + this.records.delete(instance); + this._controller.clearSavesOfInstance(instanceId); + this._controller.users.clearStatsOfInstance(instanceId); + + await this._notifyStatusChanged(instance, prevStatus); + } +} diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceRecord.ts similarity index 59% rename from packages/controller/src/InstanceInfo.ts rename to packages/controller/src/InstanceRecord.ts index 836ad472..9ad0db58 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceRecord.ts @@ -1,12 +1,8 @@ import * as lib from "@clusterio/lib"; import { Static, Type } from "@sinclair/typebox"; - -/** - * Runtime status of an instance on the controller - * @alias module:controller/src/InstanceInfo - */ -export default class InstanceInfo { +/** Underlying data class for instances on the controller */ +export default class InstanceRecord { constructor( public config: lib.InstanceConfig, public status: lib.InstanceStatus, @@ -19,6 +15,21 @@ export default class InstanceInfo { this.status = status; } + get id(): number { + return this.config.get("instance.id"); + } + + get isDeleted() { + return this.status === "deleted"; + } + + set isDeleted(value: boolean) { + if (value === false) { + throw new Error("Setting isDeleted to false is not supported, use status instead."); + } + this.status = "deleted"; + } + static jsonSchema = Type.Object({ "config": lib.InstanceConfig.jsonSchema, "status": lib.InstanceStatus, @@ -28,9 +39,9 @@ export default class InstanceInfo { "factorioVersion": Type.Optional(lib.TargetVersionSchema), }); - static fromJSON(json: Static, location: lib.ConfigLocation) { + static fromJSON(json: Static) { return new this( - lib.InstanceConfig.fromJSON(json.config, location), + lib.InstanceConfig.fromJSON(json.config, "controller"), json.status, json.gamePort, json.factorioVersion, @@ -39,6 +50,28 @@ export default class InstanceInfo { ); } + toJSON(): Static { + const json: any = { + config: this.config.toJSON(), + status: this.status, + }; + + if (this.gamePort !== undefined) { + json.gamePort = this.gamePort; + } + if (this.factorioVersion !== undefined) { + json.factorioVersion = this.factorioVersion; + } + if (this.startedAtMs !== 0) { + json.startedAtMs = this.startedAtMs; + } + if (this.updatedAtMs !== 0) { + json.updatedAtMs = this.updatedAtMs; + } + + return json; + } + toInstanceDetails() { return new lib.InstanceDetails( this.config.get("instance.name"), @@ -53,19 +86,14 @@ export default class InstanceInfo { ); } - get isDeleted() { - return this.status === "deleted"; - } - - set isDeleted(value: boolean) { - if (value === false) { - throw new Error("Setting isDeleted to false is not supported, use status instead."); - } - this.status = "deleted"; - } - - /** Shorthand for `instance.config.get("instance.id")` */ - get id():number { - return this.config.get("instance.id"); + static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) { + return new InstanceRecord( + config, + details.status, + details.gamePort, + details.factorioVersion, + details.startedAtMs, + details.updatedAtMs, + ); } } diff --git a/packages/controller/src/User.ts b/packages/controller/src/User.ts new file mode 100644 index 00000000..d82202a1 --- /dev/null +++ b/packages/controller/src/User.ts @@ -0,0 +1,146 @@ + +import { + IUser, Role, SubscribableDatastore, + PermissionError, permissions, +} from "@clusterio/lib"; + +import UserRecord from "./UserRecord"; +import { Static } from "@sinclair/typebox"; + +/** + * Represents a user as viewed from the controller. + * + * All methods are safe to use when readonly. + * Only use mutable when needing to change multiple values. + */ +export default class User extends UserRecord implements IUser { + constructor( + private _controllerUsers: SubscribableDatastore, + private _controllerRoles: SubscribableDatastore, + ...args: ConstructorParameters + ) { + super(...args); + } + + static fromJSON( + json: Static, + _controllerUsers: SubscribableDatastore, + _controllerRoles: SubscribableDatastore, + ) { + return this.fromUserRecord( + UserRecord.fromJSON(json), + _controllerUsers, + _controllerRoles, + ); + } + + static fromUserRecord( + userRecord: UserRecord, + _controllerUsers: SubscribableDatastore, + _controllerRoles: SubscribableDatastore, + ): User { + return new this( + _controllerUsers, + _controllerRoles, + userRecord.tokenValidAfter, + userRecord.name, + userRecord.roleIds, + userRecord.instances, + userRecord.isAdmin, + userRecord.isBanned, + userRecord.isWhitelisted, + userRecord.banReason, + userRecord.updatedAtMs, + userRecord.isDeleted, + userRecord.instanceStats, + ); + } + + /** Roles this user has */ + get roles(): ReadonlySet> { + return new Set([...this.roleIds].map( + id => this._controllerRoles.get(id) + ).filter( + (r): r is Role => Boolean(r) + )); + } + + /** Save the underlying user record */ + saveRecord() { + this._controllerUsers.set(this); + } + + /** Set a property on the underlying record, then save it. */ + set(key: K, value: this[K]) { + this[key] = value; + this.saveRecord(); + } + + /** + * Invalidate current tokens for the user + * + * Sets the tokenValidAfter property to the current time, which causes + * all currently issued tokens for the user to become invalid. + */ + invalidateToken() { + this.tokenValidAfter = Math.floor(Date.now() / 1000); + this.saveRecord(); + } + + /** Add a role to this user */ + addRole(roleId: number) { + const role = this._controllerRoles.get(roleId); + if (!role) { + throw new Error(`role ${roleId} does not exist`); + } + this.roleIds.add(roleId); + this.saveRecord(); + } + + /** Remove a role from user */ + removeRole(roleId: number) { + if (!this.roleIds.delete(roleId)) { + return false; + } + this.saveRecord(); + return true; + } + + /** Set a user as being online on a specific instance */ + notifyJoin(instanceId: number) { + this.instances.add(instanceId); + this.saveRecord(); + } + + /** Set a user as being offline on a specific instance */ + notifyLeave(instanceId: number) { + this.instances.delete(instanceId); + this.saveRecord(); + } + + /** Clear the stats of a player on a specific instance */ + clearInstanceStats(instanceId: number) { + this.instances.delete(instanceId); + this.instanceStats.delete(instanceId); + this.recalculatePlayerStats(); + this.saveRecord(); + } + + checkPermission(permission: string) { + if (!permissions.has(permission)) { + throw new Error(`permission ${permission} does not exist`); + } + + for (const roleId of this.roleIds) { + const role = this._controllerRoles.get(roleId); + if (!role) { + continue; + } + if (role.permissions.has("core.admin") || role.permissions.has(permission)) { + return; + } + } + + throw new PermissionError("Permission denied"); + } +} diff --git a/packages/controller/src/UserManager.ts b/packages/controller/src/UserManager.ts index 6c88a1d6..2c727f30 100644 --- a/packages/controller/src/UserManager.ts +++ b/packages/controller/src/UserManager.ts @@ -3,65 +3,45 @@ import jwt from "jsonwebtoken"; import * as lib from "@clusterio/lib"; -import ControllerUser from "./ControllerUser"; +import User from "./User"; +import UserRecord from "./UserRecord"; /** - * Manages users and roles + * Access layer for users on the controller. + * + * In performance critical paths the raw records can be accessed directly. + * Otherwise this class will wrap data in a view class with a set and save method. + * * @alias module:controller/src/UserManager */ export default class UserManager { - users: Map = new Map(); - dirty = false; - - /** - * Set of users currently online in the cluster. - */ - onlineUsers = new Set(); - constructor( - private _config: lib.ControllerConfig, - private _roles: lib.SubscribableDatastore, + public records: lib.SubscribableDatastore, + private _controllerRoles: lib.SubscribableDatastore, + private _controllerConfig: lib.ControllerConfig, ) { } - getByName(name: string) { - return this.users.get(name.toLowerCase()); - } - - async load(filePath: string): Promise { + /** + * Prior to 2.0.0-alpha.22 users and roles were stored in a single file. + * This migration separates them so they can be loaded by datastore. + * + * @param usersFilePath The src and dst file path for the users json file + * @param rolesFilePath The dst for the roles json file + */ + static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) { try { - let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" })); - if ("roles" in content) { // Split file format after 2.0.0-alpha.22 - this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole))); - await this._roles.save(); // Force a save to prevent data loss - content = content.users; + const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" })); + if (!("roles" in combinedJson)) { + return; // Files are already split, no action required } - - let duplicates = 0; - for (const serializedUser of content) { - const user = ControllerUser.fromJSON(serializedUser, this._roles); - const existingUser = this.users.get(user.id); - if (existingUser) { - // Required migration to all lowercase ids in alpha 19 - duplicates += 1; - // We assume the user with the lower online time is the duplicate - if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) { - existingUser.merge(user); - continue; // Skip users.set - } - user.merge(existingUser); - } - this.users.set(user.id, user); - } - - if (duplicates) { - const backupPath = `${filePath}.${Date.now()}.bak`; - lib.logger.warn( - `A total of ${duplicates} users were merged, a backup was written to: ${backupPath}` - ); - await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t")); + if (!("users" in combinedJson)) { + throw new Error("Legacy users json does not contain users property"); } + // We intentionally write the roles first because if it fails then the old user file will still contain them + await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles)); + await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users)); } catch (err: any) { if (err.code !== "ENOENT") { throw err; @@ -69,66 +49,116 @@ export default class UserManager { } } - async save(filePath: string): Promise { - this.dirty = false; - await lib.safeOutputFile(filePath, - JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t") + /* ------------------------------------------------------------------------- */ + /* Basic Accessors */ + /* ------------------------------------------------------------------------- */ + + has(id: UserRecord["id"]) { + return this.records.has(id); + } + + getMutable(id: UserRecord["id"]) { + const userRecord = this.records.getMutable(id); + if (!userRecord) { + return undefined; + } + + return User.fromUserRecord( + userRecord, + this.records, + this._controllerRoles, ); } + get(id: UserRecord["id"]): Readonly | undefined { + return this.getMutable(id); + } + + hasName(name: UserRecord["name"]) { + return this.has(name.toLowerCase()); + } + + getByNameMutable(name: UserRecord["name"]) { + return this.getMutable(name.toLowerCase()); + } + + getByName(name: UserRecord["name"]) { + return this.get(name.toLowerCase()); + } + + * valuesMutable(): IterableIterator { + const roles = this._controllerRoles; + const users = this.records; + + for (const record of users.values()) { + yield User.fromUserRecord(record, users, roles); + } + } + + values(): IterableIterator> { + return this.valuesMutable(); + } + + /* ------------------------------------------------------------------------- */ + /* Lifecycle */ + /* ------------------------------------------------------------------------- */ + /** * Creates a new user and add it to the user database. * @param name - Name of the user to create. * @returns The created user. */ - createUser(name: string): ControllerUser { + createUser(name: string): User { if (this.getByName(name)) { throw new Error(`User '${name}' already exists`); } let roles = new Set(); - let defaultRoleId = this._config.get("controller.default_role_id"); - if (defaultRoleId !== null && this._roles.has(defaultRoleId)) { + let defaultRoleId = this._controllerConfig.get("controller.default_role_id"); + if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) { roles.add(defaultRoleId); } - let user = new ControllerUser(this._roles, 0, name, roles); - this.users.set(user.id, user); - this.dirty = true; + const user = new User(this.records, this._controllerRoles, 0, name, roles); + this.records.set(user); return user; } + /** Get or create a user with a given name. */ + getOrCreateUser(name: string): User { + return this.getByNameMutable(name) ?? this.createUser(name); + } + + /** + * Deletes an existing user + * + * @param user - user to delete + */ + deleteUser(user: UserRecord) { + return this.records.delete(user); + } + + /* ------------------------------------------------------------------------- */ + /* Helpers */ + /* ------------------------------------------------------------------------- */ + /** * Sign access token for the given user * * @param user - user to sign token for * @returns JWT access token for the user. */ - signUserToken(user: ControllerUser): string { + signUserToken(user: UserRecord): string { return jwt.sign( { aud: "user", user: user.id }, - Buffer.from(this._config.get("controller.auth_secret"), "base64") + Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64") ); } - notifyJoin(user: ControllerUser, instance_id: number) { - user.instances.add(instance_id); - this.onlineUsers.add(user); - } - - notifyLeave(user: ControllerUser, instance_id: number) { - user.instances.delete(instance_id); - if (!user.instances.size) { - this.onlineUsers.delete(user); - } - } - + /** Clear all player stats for specific instance */ clearStatsOfInstance(instanceId: number) { - for (const user of this.users.values()) { - this.notifyLeave(user, instanceId); - user.instanceStats.delete(instanceId); - user.recalculatePlayerStats(); + for (const user of this.values()) { + user.clearInstanceStats(instanceId); } - this.dirty = true; } } diff --git a/packages/controller/src/UserRecord.ts b/packages/controller/src/UserRecord.ts new file mode 100644 index 00000000..954046be --- /dev/null +++ b/packages/controller/src/UserRecord.ts @@ -0,0 +1,102 @@ +import { UserDetails } from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +/** Underlying data class for users on the controller */ +export default class UserRecord extends UserDetails { + constructor( + /** Unix time in seconds the user token must be issued after to be valid. */ + public tokenValidAfter = 0, + ...args: ConstructorParameters + ) { + super(...args); + } + + static jsonSchema = Type.Object({ + token_valid_after: Type.Optional(Type.Number()), + ...UserDetails.jsonSchema.properties, + }); + + // _a and _b are a ts workaround for allowing User to have extra arguments + static fromJSON(json: Static, _a?: any, _b?: any) { + return this.fromUserDetails( + UserDetails.fromJSON(json), + json.token_valid_after, + ); + } + + toJSON() { + const json = super.toJSON() as Static; + + if (this.tokenValidAfter > 0) { + json.token_valid_after = this.tokenValidAfter; + } + + return json; + } + + static fromUserDetails( + userDetails: UserDetails, + tokenValidAfter = 0, + ) { + return new this( + tokenValidAfter, + userDetails.name, + userDetails.roleIds, + userDetails.instances, + userDetails.isAdmin, + userDetails.isBanned, + userDetails.isWhitelisted, + userDetails.banReason, + userDetails.updatedAtMs, + userDetails.isDeleted, + userDetails.instanceStats, + ); + } + + toUserDetails() { + return new UserDetails( + this.name, + this.roleIds, + this.instances, + this.isAdmin, + this.isBanned, + this.isWhitelisted, + this.banReason, + this.updatedAtMs, + this.isDeleted, + this.instanceStats, + ); + } + + /** + * Merge another user into this one, must have matching ids, user is not deleted + * @param otherUser - User who's details are merged from + */ + merge(otherUser: UserDetails) { + if (this.id !== otherUser.id) { + throw new Error("Cannot merge users with different ids"); + } + + // Merge properties + this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]); + this.instances = new Set([...this.instances, ...otherUser.instances]); + this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather || + this.isBanned = this.isBanned || otherUser.isBanned; + this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted; + this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason; + this.updatedAtMs = Date.now(); + + // Merge instance stats + const thisInstanceStats = this.instanceStats; + for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) { + if (thisInstanceStats.has(instanceId)) { + thisInstanceStats.get(instanceId)!.merge(instanceStats); + } else { + thisInstanceStats.set(instanceId, instanceStats); + } + } + + // Player stats must be recalculated after modifying instance stats + this.recalculatePlayerStats(); + } +} diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts index c30b0f5c..819f8970 100644 --- a/packages/controller/src/WsServer.ts +++ b/packages/controller/src/WsServer.ts @@ -334,7 +334,7 @@ ${err.stack}` throw new Error("unexpected JsonWebToken type"); } - user = this.controller.userManager.getByName(tokenPayload.user); + user = this.controller.users.getByNameMutable(tokenPayload.user); if (!user) { throw new Error("invalid user"); } diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index adb1c716..10d75533 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -189,7 +189,7 @@ function validateUserToken(req: Request, res: Response, next: any) { if (typeof tokenPayload === "string") { throw new Error("unexpected JsonWebToken type"); } - let user = req.app.locals.controller.userManager.getByName(tokenPayload.user); + let user = req.app.locals.controller.users.getByName(tokenPayload.user); if (!user) { throw new Error("invalid user"); } diff --git a/packages/create/templates/plugin-ts/controller.ts b/packages/create/templates/plugin-ts/controller.ts index 450b54dd..59fb296d 100644 --- a/packages/create/templates/plugin-ts/controller.ts +++ b/packages/create/templates/plugin-ts/controller.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller"; +import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller"; //%if multi_context // Messages requires multi context import { @@ -37,7 +37,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } //%if instance - async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { + async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`); } //%endif @@ -50,7 +50,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.logger.info("controller::onShutdown"); } - async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) { + async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`); } //%if multi_context diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index b3bc909b..e59d6593 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -32,7 +32,7 @@ function checkRequestSaveName(name: string) { * * Looks through all sub-dirs of the provided directory for valid * instance definitions and return a mapping of instance id to - * instanceInfo objects. + * instance configs and the root instance paths. * * @param instancesDir - Directory containing instances * @returns @@ -40,7 +40,7 @@ function checkRequestSaveName(name: string) { * @internal */ async function discoverInstances(instancesDir: string) { - let instanceInfos = new Map(); + const instances = new Map(); for (let entry of await fs.readdir(instancesDir, { withFileTypes: true })) { if (entry.isDirectory()) { let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning); @@ -60,19 +60,19 @@ async function discoverInstances(instancesDir: string) { continue; } - if (instanceInfos.has(instanceConfig.get("instance.id"))) { + if (instances.has(instanceConfig.get("instance.id"))) { logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`); continue; } logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`); - instanceInfos.set(instanceConfig.get("instance.id"), { + instances.set(instanceConfig.get("instance.id"), { path: instancePath, config: instanceConfig, }); } } - return instanceInfos; + return instances; } const instanceStartingMessages = new Set([ @@ -114,10 +114,10 @@ export class HostRouter { dst.type === lib.Address.controller || dst.type === lib.Address.control || dst.type === lib.Address.host && dst.id !== this.host.config.get("host.id") - || dst.type === lib.Address.instance && !this.host.instanceInfos.has(dst.id) + || dst.type === lib.Address.instance && !this.host.assignedInstances.has(dst.id) ) { nextHop = this.host; - } else if (dst.type === lib.Address.instance && this.host.instanceInfos.has(dst.id)) { + } else if (dst.type === lib.Address.instance && this.host.assignedInstances.has(dst.id)) { nextHop = this.host.instanceConnections.get(dst.id)!; } @@ -244,6 +244,12 @@ export class HostRouter { } } +// Instance information required to location and load an instance +type UnloadedInstance = { + path: string, + config: lib.InstanceConfig, +}; + /** * Handles running the host * @@ -263,9 +269,12 @@ export default class Host extends lib.Link { /** Mapping of plugin name to loaded plugin */ plugins: Map = new Map(); + /** A map from instance id to instance connection. Only present when instance is running. */ instanceConnections = new Map(); - discoveredInstanceInfos = new Map(); - instanceInfos = new Map(); + /** A map of instances that exist in the instance directory of the host */ + discoveredInstances = new Map(); + /** A map of instances that are assigned to this host */ + assignedInstances = new Map(); adminlist = new Set(); banlist = new Map(); @@ -652,16 +661,16 @@ export default class Host extends lib.Link { async handleInstanceAssignInternalRequest(request: lib.InstanceAssignInternalRequest) { let { instanceId, config } = request; - let instanceInfo = this.instanceInfos.get(instanceId); - if (instanceInfo) { - instanceInfo.config.update(config, true, "controller"); - logger.verbose(`Updated config for ${instanceInfo.path}`, this.instanceLogMeta(instanceId, instanceInfo)); + let instance = this.assignedInstances.get(instanceId); + if (instance) { + instance.config.update(config, true, "controller"); + logger.verbose(`Updated config for ${instance.path}`, this.instanceLogMeta(instanceId, instance)); } else { - instanceInfo = this.discoveredInstanceInfos.get(instanceId); - if (instanceInfo) { - await Instance.populate_folders(instanceInfo.path); - instanceInfo.config.update(config, true, "controller"); + instance = this.discoveredInstances.get(instanceId); + if (instance) { + await Instance.populate_folders(instance.path); + instance.config.update(config, true, "controller"); } else { let instanceConfig = new lib.InstanceConfig("host", Host.instanceConfigWarning); @@ -672,16 +681,16 @@ export default class Host extends lib.Link { logger.info(`Creating ${instanceDir}`); await Instance.populate_folders(instanceDir); - instanceInfo = { + instance = { path: instanceDir, config: instanceConfig, }; - this.discoveredInstanceInfos.set(instanceId, instanceInfo); + this.discoveredInstances.set(instanceId, instance); } - this.instanceInfos.set(instanceId, instanceInfo); - logger.verbose(`assigned instance ${instanceInfo.config.get("instance.name")}`); + this.assignedInstances.set(instanceId, instance); + logger.verbose(`assigned instance ${instance.config.get("instance.name")}`); } // Somewhat hacky, but in the event of a lost session the status is @@ -696,45 +705,45 @@ export default class Host extends lib.Link { instanceConnection ? instanceConnection.instance.server.version // TODO: factorio.version is not validated - : instanceInfo.config.get("factorio.version") as lib.TargetVersion, + : instance.config.get("factorio.version") as lib.TargetVersion, ) ); // Send the new list of saves for this assigned instance to the controller. - await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves")); // save a copy of the instance config - await instanceInfo.config.save(); + await instance.config.save(); } async handleInstanceUnassignInternalRequest(request: lib.InstanceUnassignInternalRequest) { let instanceId = request.instanceId; - let instanceInfo = this.instanceInfos.get(instanceId); - if (instanceInfo) { + let instance = this.assignedInstances.get(instanceId); + if (instance) { let instanceConnection = this.instanceConnections.get(instanceId); if (instanceConnection && ["starting", "running"].includes(instanceConnection.instance.status)) { await instanceConnection.send(new lib.InstanceStopRequest()); } - this.instanceInfos.delete(instanceId); - logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`); + this.assignedInstances.delete(instanceId); + logger.verbose(`unassigned instance ${instance.config.get("instance.name")}`); } } - instanceLogMeta(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) { - instanceInfo = instanceInfo || this.instanceInfos.get(instanceId); - if (!instanceInfo) { + instanceLogMeta(instanceId: number, instance?: { config: lib.InstanceConfig }) { + instance = instance || this.assignedInstances.get(instanceId); + if (!instance) { return { instance_id: instanceId, instance_name: String(instanceId) }; } - return { instance_id: instanceId, instance_name: instanceInfo.config.get("instance.name") }; + return { instance_id: instanceId, instance_name: instance.config.get("instance.name") }; } - getRequestInstanceInfo(instanceId: number) { - let instanceInfo = this.instanceInfos.get(instanceId); - if (!instanceInfo) { + getRequestInstance(instanceId: number) { + let instance = this.assignedInstances.get(instanceId); + if (!instance) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); } - return instanceInfo; + return instance; } /** @@ -743,7 +752,7 @@ export default class Host extends lib.Link { * @returns Assigned game port or undefined if it does not exist. */ gamePort(instanceId: number) { - const instance = this.discoveredInstanceInfos.get(instanceId); + const instance = this.discoveredInstances.get(instanceId); if (!instance) { return undefined; } @@ -756,7 +765,7 @@ export default class Host extends lib.Link { assignGamePort(instanceId: number) { const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1); - for (const [id, instance] of this.instanceInfos) { + for (const [id, instance] of this.assignedInstances) { if (id === instanceId) { continue; } @@ -768,7 +777,7 @@ export default class Host extends lib.Link { availablePorts.delete(port); } } - const instanceToAssign = this.instanceInfos.get(instanceId)!; + const instanceToAssign = this.assignedInstances.get(instanceId)!; const assignedPort = instanceToAssign.config.get("factorio.host_assigned_game_port"); if (assignedPort !== null && availablePorts.has(assignedPort)) { return assignedPort; @@ -788,7 +797,7 @@ export default class Host extends lib.Link { * @returns connection to instance. */ async _connectInstance(instanceId: number) { - let instanceInfo = this.getRequestInstanceInfo(instanceId); + let unloadedInstance = this.getRequestInstance(instanceId); if (this.instanceConnections.has(instanceId)) { throw new lib.RequestError(`Instance with ID ${instanceId} is running`); } @@ -797,7 +806,9 @@ export default class Host extends lib.Link { let instanceAddress = new lib.Address(lib.Address.instance, instanceId); let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress); let instance = new Instance( - this, connectionClient, instanceInfo.path, this.config.get("host.factorio_directory"), instanceInfo.config + this, connectionClient, + unloadedInstance.path, this.config.get("host.factorio_directory"), + unloadedInstance.config ); let instanceConnection = new InstanceConnection(connectionServer, this, instance); @@ -896,19 +907,19 @@ export default class Host extends lib.Link { src: lib.Address, dst: lib.Address ) { - let instanceInfo = this.getRequestInstanceInfo(dst.id); - return await Instance.listSaves(dst.id, path.join(instanceInfo.path, "saves"), null); + let instance = this.getRequestInstance(dst.id); + return await Instance.listSaves(dst.id, path.join(instance.path, "saves"), null); } async handleInstanceRenameSaveRequest(request: lib.InstanceRenameSaveRequest) { let { instanceId, oldName, newName } = request; checkRequestSaveName(oldName); checkRequestSaveName(newName); - let instanceInfo = this.getRequestInstanceInfo(instanceId); + let instance = this.getRequestInstance(instanceId); try { await fs.move( - path.join(instanceInfo.path, "saves", oldName), - path.join(instanceInfo.path, "saves", newName), + path.join(instance.path, "saves", oldName), + path.join(instance.path, "saves", newName), { overwrite: false }, ); } catch (err: any) { @@ -917,18 +928,18 @@ export default class Host extends lib.Link { } throw err; } - await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves")); } async handleInstanceCopySaveRequest(request: lib.InstanceCopySaveRequest) { let { instanceId, source, destination } = request; checkRequestSaveName(source); checkRequestSaveName(destination); - let instanceInfo = this.getRequestInstanceInfo(instanceId); + let instance = this.getRequestInstance(instanceId); try { await fs.copy( - path.join(instanceInfo.path, "saves", source), - path.join(instanceInfo.path, "saves", destination), + path.join(instance.path, "saves", source), + path.join(instance.path, "saves", destination), { overwrite: false, errorOnExist: true }, ); } catch (err: any) { @@ -937,33 +948,33 @@ export default class Host extends lib.Link { } throw err; } - await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves")); } async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) { let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request; checkRequestSaveName(sourceName); checkRequestSaveName(targetName); - let sourceInstanceInfo = this.getRequestInstanceInfo(sourceInstanceId); - let targetInstanceInfo = this.getRequestInstanceInfo(targetInstanceId); + let sourceInstance = this.getRequestInstance(sourceInstanceId); + let targetInstance = this.getRequestInstance(targetInstanceId); // For consistency with remote transfer initiated through pullSave the // target is renamed if it already exists. targetName = await lib.findUnusedName( - path.join(targetInstanceInfo.path, "saves"), targetName, ".zip" + path.join(targetInstance.path, "saves"), targetName, ".zip" ); try { if (copy) { await fs.copy( - path.join(sourceInstanceInfo.path, "saves", sourceName), - path.join(targetInstanceInfo.path, "saves", targetName), + path.join(sourceInstance.path, "saves", sourceName), + path.join(targetInstance.path, "saves", targetName), { overwrite: true }, ); } else { await fs.move( - path.join(sourceInstanceInfo.path, "saves", sourceName), - path.join(targetInstanceInfo.path, "saves", targetName), + path.join(sourceInstance.path, "saves", sourceName), + path.join(targetInstance.path, "saves", targetName), { overwrite: true }, ); } @@ -973,8 +984,8 @@ export default class Host extends lib.Link { } throw err; } - await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstanceInfo.path, "saves")); - await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstanceInfo.path, "saves")); + await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves")); + await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves")); return targetName; } @@ -994,23 +1005,23 @@ export default class Host extends lib.Link { async handleInstanceDeleteSaveRequest(request: lib.InstanceDeleteSaveRequest) { let { instanceId, name } = request; checkRequestSaveName(name); - let instanceInfo = this.getRequestInstanceInfo(instanceId); + let instance = this.getRequestInstance(instanceId); try { - await fs.unlink(path.join(instanceInfo.path, "saves", name)); + await fs.unlink(path.join(instance.path, "saves", name)); } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${name} does not exist`); } throw err; } - await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves")); + await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves")); } async handleInstancePullSaveRequest(request: lib.InstancePullSaveRequest) { let { instanceId, streamId, name } = request; checkRequestSaveName(name); - let instanceInfo = this.getRequestInstanceInfo(instanceId); + let instance = this.getRequestInstance(instanceId); let url = new URL(this.config.get("host.controller_url")); url.pathname += `api/stream/${streamId}`; @@ -1025,7 +1036,7 @@ export default class Host extends lib.Link { throw new lib.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`); } - let savesDir = path.join(instanceInfo.path, "saves"); + let savesDir = path.join(instance.path, "saves"); let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip"); let writeStream: NodeJS.WritableStream; while (true) { @@ -1054,12 +1065,12 @@ export default class Host extends lib.Link { async handleInstancePushSaveRequest(request: lib.InstancePushSaveRequest) { let { instanceId, streamId, name } = request; checkRequestSaveName(name); - let instanceInfo = this.getRequestInstanceInfo(instanceId); + let instance = this.getRequestInstance(instanceId); let content: Buffer; try { // phin doesn't support streaming requests :( - content = await fs.readFile(path.join(instanceInfo.path, "saves", name)); + content = await fs.readFile(path.join(instance.path, "saves", name)); } catch (err: any) { if (err.code === "ENOENT") { throw new lib.RequestError(`${name} does not exist`); @@ -1084,14 +1095,14 @@ export default class Host extends lib.Link { throw new lib.RequestError(`Instance with ID ${instanceId} is running`); } - let instanceInfo = this.discoveredInstanceInfos.get(instanceId); - if (!instanceInfo) { + let instance = this.discoveredInstances.get(instanceId); + if (!instance) { throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`); } - this.discoveredInstanceInfos.delete(instanceId); - this.instanceInfos.delete(instanceId); - await fs.remove(instanceInfo.path); + this.discoveredInstances.delete(instanceId); + this.assignedInstances.delete(instanceId); + await fs.remove(instance.path); } async handleHostUpdateRequest(request: lib.HostUpdateRequest) { @@ -1141,12 +1152,12 @@ export default class Host extends lib.Link { * the host and controller with the new list of instances. */ async updateInstances() { - this.discoveredInstanceInfos = await discoverInstances(this.config.get("host.instances_directory")); + this.discoveredInstances = await discoverInstances(this.config.get("host.instances_directory")); let list = []; - for (let [instanceId, instanceInfo] of this.discoveredInstanceInfos) { + for (let [instanceId, instance] of this.discoveredInstances) { let instanceConnection = this.instanceConnections.get(instanceId); list.push(new lib.HostInstanceUpdate( - instanceInfo.config.toRemote("controller"), + instance.config.toRemote("controller"), instanceConnection ? instanceConnection.instance.status : "stopped", instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), instanceConnection ? instanceConnection.instance.server.version : undefined, @@ -1158,10 +1169,10 @@ export default class Host extends lib.Link { if (this._startup) { this._startup = false; - for (let [instanceId, instanceInfo] of this.instanceInfos) { - if (instanceInfo.config.get("instance.auto_start")) { + for (let [instanceId, instance] of this.assignedInstances) { + if (instance.config.get("instance.auto_start")) { if (this.recoveryMode) { - logger.warn(`Recovery | skipping auto startup for ${instanceInfo.config.get("instance.name")}`); + logger.warn(`Recovery | skipping auto startup for ${instance.config.get("instance.name")}`); continue; } try { @@ -1169,8 +1180,8 @@ export default class Host extends lib.Link { await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest()); } catch (err: any) { logger.error( - `Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`, - this.instanceLogMeta(instanceId, instanceInfo) + `Error during auto startup for ${instance.config.get("instance.name")}:\n${err.stack}`, + this.instanceLogMeta(instanceId, instance) ); } } @@ -1218,7 +1229,7 @@ export default class Host extends lib.Link { await this.config.save(); // Save host side in case host_assigned_game_port changed in the config. await Promise.all( - [...this.instanceInfos.values()].map(instanceInfo => instanceInfo.config.save()) + [...this.assignedInstances.values()].map(instance => instance.config.save()) ); try { diff --git a/packages/lib/src/data/HostDetails.ts b/packages/lib/src/data/HostDetails.ts new file mode 100644 index 00000000..c0637b93 --- /dev/null +++ b/packages/lib/src/data/HostDetails.ts @@ -0,0 +1,77 @@ +import { Type, Static } from "@sinclair/typebox"; + +export default class HostDetails { + constructor( + /** Version of the host software. The controller may warn if there is a mismatch. */ + public version: string, + /** Human-readable display name of the host. */ + public name: string, + /** Unique numeric identifier for this host. */ + public id: number, + /** Whether the host is currently connected to the controller. */ + public connected: boolean, + /** Address from which the host has connected to the controller. */ + public remoteAddress = "", + /** Public-facing IP address of the host, if available. */ + public publicAddress: string = "", + /** Unix timestamp (sec) after which issued tokens are considered valid. */ + public tokenValidAfter: number = 0, + /** Unix timestamp (ms) indicating when this record was last updated. */ + public updatedAtMs = 0, + /** Flag indicating the host record has been removed on the controller. */ + public isDeleted = false + ) {} + + static jsonSchema = Type.Object({ + "version": Type.String(), + "name": Type.String(), + "id": Type.Integer(), + "connected": Type.Boolean(), + "remoteAddress": Type.Optional(Type.String()), + "publicAddress": Type.Optional(Type.String()), + "tokenValidAfter": Type.Optional(Type.Number()), + "updatedAtMs": Type.Optional(Type.Number()), + "isDeleted": Type.Optional(Type.Boolean()), + }); + + static fromJSON(json: Static) { + return new this( + json.version, + json.name, + json.id, + json.connected, + json.remoteAddress, + json.publicAddress, + json.tokenValidAfter, + json.updatedAtMs, + json.isDeleted + ); + } + + toJSON() { + const json = { + version: this.version, + name: this.name, + id: this.id, + connected: this.connected, + } as Static; + + if (this.remoteAddress !== "") { + json.remoteAddress = this.remoteAddress; + } + if (this.publicAddress !== "") { + json.publicAddress = this.publicAddress; + } + if (this.tokenValidAfter !== 0) { + json.tokenValidAfter = this.tokenValidAfter; + } + if (this.updatedAtMs !== 0) { + json.updatedAtMs = this.updatedAtMs; + } + if (this.isDeleted !== false) { + json.isDeleted = this.isDeleted; + } + + return json; + } +} diff --git a/packages/lib/src/data/InstanceDetails.ts b/packages/lib/src/data/InstanceDetails.ts new file mode 100644 index 00000000..04c1a686 --- /dev/null +++ b/packages/lib/src/data/InstanceDetails.ts @@ -0,0 +1,108 @@ +import { Static, Type } from "@sinclair/typebox"; +import { StringEnum } from "./composites"; +import { TargetVersion, TargetVersionSchema } from "./version"; + +export const InstanceStatus = StringEnum([ + "unknown", + "unassigned", + "stopped", + "starting", + "running", + "stopping", + "creating_save", + "exporting_data", + "deleted", +]); + +/** + * Current status of the instance. One of: + * - `unknown`: Instance is assigned to a host but this host is currently + * not connected to the contreller. + * - `unassigned`: Instance is not assigned to a a host and exists only on + * the controller. + * - `stopped`: Instance is stopped. + * - `starting`: Instance is in the process of starting up. + * - `running`: Instance is running normally. + * - `stopping`: Instance is in the process of stopping. + * - `creating_save`: Instance is in the process of creating a save. + * - `exporting_data`: Instance is in the process of exporting game data. + * - `deleted`: Instance has been deleted. + */ +export type InstanceStatus = Static; + +export default class InstanceDetails { + constructor( + public name: string, + public id: number, + public assignedHost: number | undefined, + public gamePort: number | undefined, + public status: InstanceStatus, + public factorioVersion: TargetVersion | undefined, + public startedAtMs = 0, + /** Millisecond Unix timestamp this entry was last updated at */ + public updatedAtMs = 0, + public excludeFromStartAll = false + ) {} + + get isDeleted() { + return this.status === "deleted"; + } + + static jsonSchema = Type.Object({ + "name": Type.String(), + "id": Type.Integer(), + "assignedHost": Type.Optional(Type.Integer()), + "gamePort": Type.Optional(Type.Integer()), + "status": StringEnum([ + "unknown", "unassigned", "stopped", "starting", "running", "stopping", + "creating_save", "exporting_data", "deleted", + ]), + "factorioVersion": Type.Optional(TargetVersionSchema), + "startedAtMs": Type.Optional(Type.Number()), + "updatedAtMs": Type.Optional(Type.Number()), + "excludeFromStartAll": Type.Optional(Type.Boolean()), + }); + + static fromJSON(json: Static) { + return new this( + json.name, + json.id, + json.assignedHost, + json.gamePort, + json.status, + json.factorioVersion, + json.startedAtMs, + json.updatedAtMs, + json.excludeFromStartAll + ); + } + + toJSON() { + const json = { + name: this.name, + id: this.id, + status: this.status, + } as Static; + + if (this.assignedHost !== undefined) { + json.assignedHost = this.assignedHost; + } + if (this.gamePort !== undefined) { + json.gamePort = this.gamePort; + } + if (this.factorioVersion !== undefined) { + json.factorioVersion = this.factorioVersion; + } + if (this.startedAtMs !== 0) { + json.startedAtMs = this.startedAtMs; + } + if (this.updatedAtMs !== 0) { + json.updatedAtMs = this.updatedAtMs; + } + if (this.excludeFromStartAll !== false) { + json.excludeFromStartAll = this.excludeFromStartAll; + } + + return json; + } +} diff --git a/packages/lib/src/data/User.ts b/packages/lib/src/data/UserDetails.ts similarity index 69% rename from packages/lib/src/data/User.ts rename to packages/lib/src/data/UserDetails.ts index 641c3cab..5a99dfed 100644 --- a/packages/lib/src/data/User.ts +++ b/packages/lib/src/data/UserDetails.ts @@ -2,12 +2,10 @@ import { Static, Type } from "@sinclair/typebox"; import PlayerStats from "./PlayerStats"; -/** - * Represeents a user in the cluster - * - * Holds data about a Factorio user in the cluster. - */ -export default class User { +/** Represents a user in the cluster */ +export default class UserDetails { + public playerStats: PlayerStats; + constructor( /** Factorio user name. */ public name: string, @@ -27,11 +25,15 @@ export default class User { public updatedAtMs = 0, /** True if this user object has been removed from the cluster. */ public isDeleted = false, - /** Combined statistics for the player this user account is tied to. */ - public playerStats = new PlayerStats(), /** Per instance statistics for the player this user account is tied to. */ public instanceStats = new Map(), ) { + this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats); + } + + /** Name of the user, used by event subscriptions and to ensure case insensitivity */ + get id() { + return this.name.toLowerCase(); } static jsonSchema = Type.Object({ @@ -51,17 +53,13 @@ export default class User { ), }); - static fromJSON(json: Static, _hack?: any) { - const roleIds = new Set(json.roles ?? []); + static fromJSON(json: Static) { const instanceStats = new Map( - (json.instance_stats ?? []).map( - ([id, stats]) => [id, PlayerStats.fromJSON(stats)] - ) + (json.instance_stats ?? []).map(([id, stats]) => [id, PlayerStats.fromJSON(stats)]) ); - const playerStats = User._calculatePlayerStats(instanceStats); return new this( json.name, - roleIds, + new Set(json.roles), new Set(json.instances), json.is_admin, json.is_banned, @@ -69,13 +67,12 @@ export default class User { json.ban_reason, json.updated_at_ms, json.is_deleted, - playerStats, instanceStats, ); } toJSON() { - let json: Static = { + let json: Static = { name: this.name, }; @@ -119,7 +116,7 @@ export default class User { } recalculatePlayerStats() { - this.playerStats = User._calculatePlayerStats(this.instanceStats); + this.playerStats = UserDetails._calculatePlayerStats(this.instanceStats); } static _calculatePlayerStats(instanceStatsMap: Map) { @@ -149,44 +146,13 @@ export default class User { } return playerStats; } - - /** - * Merge another user into this one, must have matching ids, user is not deleted - * @param otherUser - User who's details are merged from - */ - merge(otherUser: User) { - if (this.id !== otherUser.id) { - throw new Error("Cannot merge users with different ids"); - } - - // Merge properties - this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]); - this.instances = new Set([...this.instances, ...otherUser.instances]); - this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather || - this.isBanned = this.isBanned || otherUser.isBanned; - this.isBanned = this.isWhitelisted || otherUser.isWhitelisted; - this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason; - this.updatedAtMs = Date.now(); - - // Merge instance stats - const thisInstanceStats = this.instanceStats; - for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) { - if (thisInstanceStats.has(instanceId)) { - thisInstanceStats.get(instanceId)!.merge(instanceStats); - } else { - thisInstanceStats.set(instanceId, instanceStats); - } - } - this.recalculatePlayerStats(); - } - - /** Name of the user, used by event subscriptions and to ensure case insensitivity */ - get id() { - return this.name.toLowerCase(); - } } -export interface IControllerUser extends User { +/** + * This interface is implemented by User on the controller. + * It is exposed here to allow message definitions to create permission methods. + */ +export interface IUser extends UserDetails { /** * Check if a given permission is granted * diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index 5adf08e6..d93dc7d2 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -12,8 +12,10 @@ export { default as ModuleInfo } from "./ModuleInfo"; export { default as Permission } from "./Permission"; export { default as PlayerStats } from "./PlayerStats"; export { default as Role } from "./Role"; -export { default as User } from "./User"; -export type { IControllerUser } from "./User"; +export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails"; +export { default as HostDetails } from "./HostDetails"; +export { default as UserDetails } from "./UserDetails"; +export type { IUser } from "./UserDetails"; export * from "./composites"; export * from "./messages_core"; export * from "./messages_controller"; diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index 66399f19..f1499992 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox"; import { jsonArray, plainJson, StringEnum } from "./composites"; import { CollectorResultSerialized } from "../prometheus"; import { HostConfig } from "../config/definitions"; +import HostDetails from "./HostDetails"; export class HostStopRequest { declare ["constructor"]: typeof HostStopRequest; @@ -82,47 +83,6 @@ export class HostConfigSetPropRequest { } } -export class HostDetails { - constructor( - public version: string, - public name: string, - public id: number, - public connected: boolean, - public remoteAddress = "", - public publicAddress: string = "", - public tokenValidAfter: number = 0, - /** Millisecond Unix timestamp this entry was last updated at */ - public updatedAtMs = 0, - public isDeleted = false, - ) { } - - static jsonSchema = Type.Object({ - "version": Type.String(), - "name": Type.String(), - "id": Type.Integer(), - "connected": Type.Boolean(), - "remoteAddress": Type.Optional(Type.String()), - "publicAddress": Type.Optional(Type.String()), - "tokenValidAfter": Type.Optional(Type.Number()), - "updatedAtMs": Type.Optional(Type.Number()), - "isDeleted": Type.Optional(Type.Boolean()), - }); - - static fromJSON(json: Static) { - return new this( - json.version, - json.name, - json.id, - json.connected, - json.remoteAddress, - json.publicAddress, - json.tokenValidAfter, - json.updatedAtMs, - json.isDeleted, - ); - } -} - export class HostListRequest { declare ["constructor"]: typeof HostListRequest; static type = "request" as const; diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 91d8c0b4..d138f3ea 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -2,87 +2,11 @@ import { Type, Static } from "@sinclair/typebox"; import PlayerStats from "./PlayerStats"; import { JsonString, StringEnum, jsonArray, plainJson } from "./composites"; import { InstanceConfig } from "../config"; -import type { IControllerUser } from "./User"; +import type { IUser } from "./UserDetails"; import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version"; - - -export const InstanceStatus = StringEnum([ - "unknown", - "unassigned", - "stopped", - "starting", - "running", - "stopping", - "creating_save", - "exporting_data", - "deleted", -]); - -/** - * Current status of the instance. One of: - * - `unknown`: Instance is assigned to a host but this host is currently - * not connected to the contreller. - * - `unassigned`: Instance is not assigned to a a host and exists only on - * the controller. - * - `stopped`: Instance is stopped. - * - `starting`: Instance is in the process of starting up. - * - `running`: Instance is running normally. - * - `stopping`: Instance is in the process of stopping. - * - `creating_save`: Instance is in the process of creating a save. - * - `exporting_data`: Instance is in the process of exporting game data. - * - `deleted`: Instance has been deleted. - */ -export type InstanceStatus = Static; - -export class InstanceDetails { - constructor( - public name: string, - public id: number, - public assignedHost: number | undefined, - public gamePort: number | undefined, - public status: InstanceStatus, - public factorioVersion: TargetVersion | undefined, - public startedAtMs = 0, - /** Millisecond Unix timestamp this entry was last updated at */ - public updatedAtMs = 0, - public excludeFromStartAll = false, - ) { } - - static jsonSchema = Type.Object({ - "name": Type.String(), - "id": Type.Integer(), - "assignedHost": Type.Optional(Type.Integer()), - "gamePort": Type.Optional(Type.Integer()), - "status": StringEnum([ - "unknown", "unassigned", "stopped", "starting", "running", "stopping", - "creating_save", "exporting_data", "deleted", - ]), - "factorioVersion": Type.Optional(TargetVersionSchema), - "startedAtMs": Type.Optional(Type.Number()), - "updatedAtMs": Type.Optional(Type.Number()), - "excludeFromStartAll": Type.Optional(Type.Boolean()), - }); - - static fromJSON(json: Static) { - return new this( - json.name, - json.id, - json.assignedHost, - json.gamePort, - json.status, - json.factorioVersion, - json.startedAtMs, - json.updatedAtMs, - json.excludeFromStartAll, - ); - } - - get isDeleted() { - return this.status === "deleted"; - } -} +import InstanceDetails, { InstanceStatus } from "./InstanceDetails"; export class InstanceDetailsGetRequest { declare ["constructor"]: typeof InstanceDetailsGetRequest; @@ -539,7 +463,7 @@ export class InstanceTransferSaveRequest { static type = "request" as const; static src = ["control", "controller"] as const; static dst = ["controller", "host"] as const; - static permission(user: IControllerUser, message: MessageRequest) { + static permission(user: IUser, message: MessageRequest) { user.checkPermission("core.instance.save.transfer"); if (typeof message.data === "object" && message.data !== null) { const data = message.data as { copy: boolean, sourceName: string, targetName: string }; diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index cb35eb6c..2c73d8be 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -1,5 +1,5 @@ import { Type, Static } from "@sinclair/typebox"; -import User, { IControllerUser } from "./User"; +import UserDetails, { IUser } from "./UserDetails"; import { StringEnum, jsonArray, plainJson } from "./composites"; import { MessageRequest } from "./messages_core"; @@ -22,7 +22,7 @@ export class UserGetRequest { return new this(json.name); } - static Response = User; + static Response = UserDetails; } export class UserListRequest { @@ -31,7 +31,7 @@ export class UserListRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.user.list" as const; - static Response = jsonArray(User); + static Response = jsonArray(UserDetails); } export class UserCreateRequest { @@ -198,15 +198,15 @@ export class UserUpdatesEvent { static permission = "core.user.subscribe" as const; constructor( - public updates: User[], + public updates: UserDetails[], ) { } static jsonSchema = Type.Object({ - "updates": Type.Array(User.jsonSchema), + "updates": Type.Array(UserDetails.jsonSchema), }); static fromJSON(json: Static) { - return new this(json.updates.map(update => User.fromJSON(update))); + return new this(json.updates.map(update => UserDetails.fromJSON(update))); } } @@ -250,7 +250,7 @@ export class UserBulkImportRequest { static type = "request" as const; static src = "control" as const; static dst = "controller" as const; - static permission(user: IControllerUser, message: MessageRequest) { + static permission(user: IUser, message: MessageRequest) { if (typeof message.data === "object" && message.data !== null) { const data = message.data as Static; // Check if this is a restore or import request diff --git a/packages/lib/src/datastore.ts b/packages/lib/src/datastore.ts index 2eb56343..0dae1a57 100644 --- a/packages/lib/src/datastore.ts +++ b/packages/lib/src/datastore.ts @@ -53,7 +53,7 @@ export class JsonDatastoreProvider< private filePath: string, private fromJson: (json: J) => V = v => v as any as V, private migrations: (rawJson: Record) => Record = v => v as Record, - private finalise: (obj: V) => V = v => v, + private finalise: (obj: V, map: Map) => V | undefined = v => v, ) { super(); } @@ -82,7 +82,24 @@ export class JsonDatastoreProvider< const serialized = this.migrations(rawJson); // Convert to data class objects - return new Map(Object.entries(serialized).map(([k, v]) => [k, this.finalise(this.fromJson(v))])); + let serializedCount = 0; + const result = new Map(); + for (const [key, raw] of Object.entries(serialized)) { + const final = this.finalise(this.fromJson(raw), result); + if (final !== undefined) { result.set(key, final); } + serializedCount += 1; + } + + // Save a backup if the map does not contain all values (happens where there are duplicates) + if (result.size < serializedCount) { + const backupPath = `${this.filePath}.${Date.now()}.bak`; + logger.warn( + `Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}` + ); + await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t")); + } + + return result; } } @@ -95,7 +112,7 @@ export class JsonIdDatastoreProvider< private filePath: string, private fromJson: (json: J) => V = v => v as any as V, private migrations: (rawJson: Array) => Array = v => v as Array, - private finalise: (obj: V) => V = v => v, + private finalise: (obj: V, map: Map) => V | undefined = v => v, ) { super(); } @@ -124,10 +141,24 @@ export class JsonIdDatastoreProvider< const serialized = this.migrations(rawJson); // Convert to data class objects - return new Map(serialized.map((e) => { - const v = this.finalise(this.fromJson(e)); - return [v.id, v]; - })); + let serializedCount = 0; + const result = new Map(); + for (const raw of serialized) { + const final = this.finalise(this.fromJson(raw), result); + if (final !== undefined) { result.set(final.id, final); } + serializedCount += 1; + } + + // Save a backup if the map does not contain all values (happens where there are duplicates) + if (result.size < serializedCount) { + const backupPath = `${this.filePath}.${Date.now()}.bak`; + logger.warn( + `Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}` + ); + await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t")); + } + + return result; } } diff --git a/packages/lib/src/link/link.ts b/packages/lib/src/link/link.ts index 4e7436f8..ac15d5c6 100644 --- a/packages/lib/src/link/link.ts +++ b/packages/lib/src/link/link.ts @@ -8,7 +8,7 @@ import { BaseConnector, WebSocketBaseConnector } from "./connectors"; import { strict as assert } from "assert"; import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; import type { - AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IControllerUser, + AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser, } from "../data"; export interface Request { @@ -18,7 +18,7 @@ export interface Request { type: "request"; src: AddressType | readonly AddressType[]; dst: AddressType | readonly AddressType[]; - permission?: null | string | ((user: IControllerUser, message: MessageRequest) => void); + permission?: null | string | ((user: IUser, message: MessageRequest) => void); plugin?: string; Response?: JSONDeserialisable, } @@ -32,7 +32,7 @@ export interface Event { type: "event"; src: AddressType | readonly AddressType[]; dst: AddressType | readonly AddressType[]; - permission?: null | string | ((user: IControllerUser, message: MessageEvent) => void); + permission?: null | string | ((user: IUser, message: MessageEvent) => void); plugin?: string; } } diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index 21b07fc6..f73374a1 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -1,6 +1,6 @@ import { Type, Static } from "@sinclair/typebox"; import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link"; -import { Address, MessageRequest, IControllerUser, plainJson, JsonBoolean } from "./data"; +import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data"; import isDeepStrictEqual from "./is_deep_strict_equal"; import { RequestError, SessionLost } from "./errors"; import { logger } from "./logging"; @@ -18,7 +18,7 @@ export class SubscriptionRequest { static type = "request" as const; static src = ["control", "host", "instance"] as const; static dst = "controller" as const; - static permission(user: IControllerUser, message: MessageRequest) { + static permission(user: IUser, message: MessageRequest) { if (typeof message.data === "object" && message.data !== null) { const data = message.data as Static; const entry = Link._eventsByName.get(data[0]); diff --git a/packages/web_ui/src/components/PluginExtra.tsx b/packages/web_ui/src/components/PluginExtra.tsx index bbee34e9..436ccc53 100644 --- a/packages/web_ui/src/components/PluginExtra.tsx +++ b/packages/web_ui/src/components/PluginExtra.tsx @@ -9,7 +9,7 @@ import BaseWebPlugin from "../BaseWebPlugin"; type PluginExtraProps = { component: string; instance?: lib.InstanceDetails; - user?: lib.User; + user?: lib.UserDetails; host?: lib.HostDetails; role?: lib.Role; }; diff --git a/packages/web_ui/src/components/UsersTable.tsx b/packages/web_ui/src/components/UsersTable.tsx index 1db84900..6f35f22f 100644 --- a/packages/web_ui/src/components/UsersTable.tsx +++ b/packages/web_ui/src/components/UsersTable.tsx @@ -44,7 +44,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, const data = [...users.values()]; // Determine online predicate based on instanceId - const isUserOnline = (user: lib.User) => { + const isUserOnline = (user: lib.UserDetails) => { if (instanceId !== undefined) { return user.instances && user.instances.has(instanceId); } @@ -58,7 +58,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, { title: "Name", key: "name", - render: (_: any, user: lib.User) => ( + render: (_: any, user: lib.UserDetails) => ( {user.name} @@ -69,11 +69,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, ), defaultSortOrder: "ascend", - sorter: (a: lib.User, b: lib.User) => strcmp(a.name, b.name), + sorter: (a: lib.UserDetails, b: lib.UserDetails) => strcmp(a.name, b.name), filterIcon: (filtered: boolean) => ( ), - onFilter: (value: string | number | boolean, record: lib.User) => record.name + onFilter: (value: string | number | boolean, record: lib.UserDetails) => record.name .toLowerCase() .includes((value as string).toLowerCase()), filterDropdownProps: { @@ -106,8 +106,10 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, key: "roles", filters: roleFilters, filterMultiple: true, - onFilter: (value: string | number | boolean, record: lib.User) => record.roleIds.has(value as number), - render: (_: any, user: lib.User) => ( + onFilter: (value: string | number | boolean, record: lib.UserDetails) => ( + record.roleIds.has(value as number) + ), + render: (_: any, user: lib.UserDetails) => ( [...user.roleIds].map((id) => ( e.stopPropagation()}> {(roles.get(id) || { name: id }).name} @@ -119,7 +121,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, // Helper to get last seen timestamp - function getLastSeenTimestamp(user: lib.User): number | undefined { + function getLastSeenTimestamp(user: lib.UserDetails): number | undefined { const stats = instanceId !== undefined ? user.instanceStats.get(instanceId) : user.playerStats; if (!stats) { return undefined; } if (stats.lastLeaveAt && stats.lastLeaveAt.getTime() > (stats.lastJoinAt?.getTime() ?? 0)) { @@ -136,11 +138,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, { title: "Play Time", key: "playTime", - render: (_: any, user: lib.User) => { + render: (_: any, user: lib.UserDetails) => { const instanceStats = user.instanceStats.get(instanceId); return instanceStats?.onlineTimeMs ? formatDuration(instanceStats.onlineTimeMs) : "-"; }, - sorter: (a: lib.User, b: lib.User) => { + sorter: (a: lib.UserDetails, b: lib.UserDetails) => { const statsA = a.instanceStats.get(instanceId); const statsB = b.instanceStats.get(instanceId); return (statsA?.onlineTimeMs ?? 0) - (statsB?.onlineTimeMs ?? 0); @@ -149,11 +151,11 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, { title: "Join Count", key: "joinCount", - render: (_: any, user: lib.User) => { + render: (_: any, user: lib.UserDetails) => { const instanceStats = user.instanceStats.get(instanceId); return instanceStats?.joinCount ?? 0; }, - sorter: (a: lib.User, b: lib.User) => { + sorter: (a: lib.UserDetails, b: lib.UserDetails) => { const statsA = a.instanceStats.get(instanceId); const statsB = b.instanceStats.get(instanceId); return (statsA?.joinCount ?? 0) - (statsB?.joinCount ?? 0); @@ -162,8 +164,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, { title: "First Seen", key: "firstSeen", - render: (_: any, user: lib.User) => formatFirstSeen(user, instanceId), - sorter: (a: lib.User, b: lib.User) => { + render: (_: any, user: lib.UserDetails) => formatFirstSeen(user, instanceId), + sorter: (a: lib.UserDetails, b: lib.UserDetails) => { const statsA = a.instanceStats.get(instanceId); const statsB = b.instanceStats.get(instanceId); const firstSeenA = statsA?.firstJoinAt?.getTime() ?? 0; @@ -177,18 +179,18 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, { title: "Online time", key: "onlineTime", - render: (_: any, user: lib.User) => (user.playerStats?.onlineTimeMs + render: (_: any, user: lib.UserDetails) => (user.playerStats?.onlineTimeMs ? formatDuration(user.playerStats.onlineTimeMs) : null), // eslint-disable-next-line max-len - sorter: (a: lib.User, b: lib.User) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), + sorter: (a: lib.UserDetails, b: lib.UserDetails) => (a.playerStats?.onlineTimeMs ?? 0) - (b.playerStats?.onlineTimeMs ?? 0), responsive: ["lg"], }, { title: "First seen", key: "firstSeen", - render: (_: any, user: lib.User) => formatFirstSeen(user), - sorter: (a: lib.User, b: lib.User) => sortFirstSeen(a, b), + render: (_: any, user: lib.UserDetails) => formatFirstSeen(user), + sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortFirstSeen(a, b), }, ); } @@ -204,7 +206,7 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, { text: "7d", value: "7d" }, { text: "30d", value: "30d" }, ], - onFilter: (value: string | number | boolean, record: lib.User) => { + onFilter: (value: string | number | boolean, record: lib.UserDetails) => { const online = isUserOnline(record); if (value === "online") { return online; @@ -227,8 +229,8 @@ export default function UsersTable({ instanceId, onlyOnline = false, pagination, return false; } }, - sorter: (a: lib.User, b: lib.User) => sortLastSeen(a, b, instanceId, instanceId), - render: (_: any, user: lib.User) => formatLastSeen(user, instanceId), + sorter: (a: lib.UserDetails, b: lib.UserDetails) => sortLastSeen(a, b, instanceId, instanceId), + render: (_: any, user: lib.UserDetails) => formatLastSeen(user, instanceId), responsive: ["lg"], }, ); diff --git a/packages/web_ui/src/model/roles.ts b/packages/web_ui/src/model/roles.ts index eb5f43e1..5e3bcffc 100644 --- a/packages/web_ui/src/model/roles.ts +++ b/packages/web_ui/src/model/roles.ts @@ -3,7 +3,7 @@ import ControlContext from "../components/ControlContext"; import * as lib from "@clusterio/lib"; -export function useRolesOfUser(user?: lib.User) { +export function useRolesOfUser(user?: lib.UserDetails) { const [roles, synced] = useRoles(); return [ user !== undefined ? new Map([...user.roleIds].map(roleId => [roleId, roles.get(roleId)])) : new Map(), diff --git a/packages/web_ui/src/model/user.tsx b/packages/web_ui/src/model/user.tsx index 981a57e0..4c5851ed 100644 --- a/packages/web_ui/src/model/user.tsx +++ b/packages/web_ui/src/model/user.tsx @@ -4,19 +4,19 @@ import ControlContext from "../components/ControlContext"; import * as lib from "@clusterio/lib"; -function getInstanceStats(user: lib.User, instanceId?: number) { +function getInstanceStats(user: lib.UserDetails, instanceId?: number) { if (instanceId === undefined) { return user.playerStats; } return user.instanceStats.get(instanceId); } -function calculateFirstSeen(user: lib.User, instanceId?: number) { +function calculateFirstSeen(user: lib.UserDetails, instanceId?: number) { const stats = getInstanceStats(user, instanceId); return stats?.firstJoinAt?.getTime(); } -function calculateLastSeen(user: lib.User, instanceId?: number) { +function calculateLastSeen(user: lib.UserDetails, instanceId?: number) { const stats = getInstanceStats(user, instanceId); if (!stats) { return undefined; @@ -30,7 +30,7 @@ function calculateLastSeen(user: lib.User, instanceId?: number) { return undefined; } -export function formatFirstSeen(user: lib.User, instanceId?: number) { +export function formatFirstSeen(user: lib.UserDetails, instanceId?: number) { const firstSeen = calculateFirstSeen(user, instanceId); if (firstSeen === undefined) { return undefined; @@ -38,7 +38,7 @@ export function formatFirstSeen(user: lib.User, instanceId?: number) { return new Date(firstSeen).toLocaleString(); } -export function formatLastSeen(user: lib.User, instanceId?: number) { +export function formatLastSeen(user: lib.UserDetails, instanceId?: number) { if (user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId)) { return Online; } @@ -49,14 +49,24 @@ export function formatLastSeen(user: lib.User, instanceId?: number) { return new Date(lastSeen).toLocaleString(); } -export function sortFirstSeen(userA: lib.User, userB: lib.User, instanceIdA?: number, instanceIdB?: number) { +export function sortFirstSeen( + userA: lib.UserDetails, + userB: lib.UserDetails, + instanceIdA?: number, + instanceIdB?: number +) { const firstSeenA = calculateFirstSeen(userA, instanceIdA) || 0; const firstSeenB = calculateFirstSeen(userB, instanceIdB) || 0; return firstSeenA - firstSeenB; } -export function sortLastSeen(userA: lib.User, userB: lib.User, instanceIdA?: number, instanceIdB?: number) { - function epoch(user: lib.User, instanceId?: number) { +export function sortLastSeen( + userA: lib.UserDetails, + userB: lib.UserDetails, + instanceIdA?: number, + instanceIdB?: number +) { + function epoch(user: lib.UserDetails, instanceId?: number) { return user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId); } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index dbd02325..8d2d657c 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -10,7 +10,7 @@ type instanceHandler = (instanceDetails: lib.InstanceDetails) => void; type saveListHandler = (saveListEvent: lib.SaveDetails) => void; type modPackHandler = (modPack: lib.ModPack) => void; type modInfoHandler = (modInfo: lib.ModInfo) => void; -type userHandler = (rawUser: lib.User) => void; +type userHandler = (rawUser: lib.UserDetails) => void; type logHandler = (info: { level: keyof typeof lib.levels, message: string }) => void; /** diff --git a/plugins/inventory_sync/controller.ts b/plugins/inventory_sync/controller.ts index 0b4051fe..203e5b48 100644 --- a/plugins/inventory_sync/controller.ts +++ b/plugins/inventory_sync/controller.ts @@ -1,4 +1,4 @@ -import { BaseControllerPlugin, type InstanceInfo } from "@clusterio/controller"; +import { BaseControllerPlugin, type InstanceRecord } from "@clusterio/controller"; import type { IpcPlayerData } from "./messages"; import fs from "fs-extra"; @@ -51,7 +51,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(msg.DatabaseStatsRequest, this.handleDatabaseStatsRequest.bind(this)); } - async onInstanceStatusChanged(instance: InstanceInfo) { + async onInstanceStatusChanged(instance: InstanceRecord) { let instanceId = instance.id; if (["unassigned", "deleted"].includes(instance.status)) { for (let [playerName, acquisitionRecord] of this.acquiredPlayers) { diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index 01a69aef..0fc43226 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -152,13 +152,13 @@ export class ControllerPlugin extends BaseControllerPlugin { for (let [player, entry] of this.players) { if (entry.playerCode === playerCode && entry.expiresMs > Date.now()) { if (entry.verifyCode === verifyCode) { - let user = this.controller.userManager.getByName(player); + let user = this.controller.users.getByName(player); if (!user) { res.send({ error: true, message: "invalid user" }); return; } - let token = this.controller.userManager.signUserToken(user); + let token = this.controller.users.signUserToken(user); res.send({ verified: true, token }); return; diff --git a/plugins/player_auth/test/plugin.js b/plugins/player_auth/test/plugin.js index 4cd2618a..c63536e5 100644 --- a/plugins/player_auth/test/plugin.js +++ b/plugins/player_auth/test/plugin.js @@ -61,7 +61,7 @@ describe("player_auth", function() { 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, { + controllerPlugin.controller.instances.records.set(id, { config: { get(field) { if (field === "player_auth.load_plugin") { @@ -85,7 +85,7 @@ describe("player_auth", function() { const result = await fetch(`${controllerUrl}/api/player_auth/servers`); assert.deepEqual(await result.json(), ["running loaded", "unnamed server"]); for (let id of [1, 2, 3, 4, 5]) { - controllerPlugin.controller.instances.delete(id); + controllerPlugin.controller.instances.records.delete(id); } }); }); @@ -286,8 +286,8 @@ describe("player_auth", function() { new SetVerifyCodeRequest("test", verify_code) ); - const user = controllerPlugin.controller.userManager.getByName("test"); - const token = controllerPlugin.controller.userManager.signUserToken(user); + const user = controllerPlugin.controller.users.getByName("test"); + const token = controllerPlugin.controller.users.signUserToken(user); const verifyResult = await postJSON( `${controllerUrl}/api/player_auth/verify`, { player_code: playerCode, verify_code, verify_token } diff --git a/test/common.js b/test/common.js index 96397548..eb75a3f2 100644 --- a/test/common.js +++ b/test/common.js @@ -1,5 +1,6 @@ "use strict"; const assert = require("assert").strict; +const { compile } = require("@clusterio/lib"); /** * Generate a flat array of tests from a matrix of inputs. @@ -21,11 +22,16 @@ function testMatrix(...arrays) { * @param {T[]} tests - The tests inputs to pass to the class constructor. */ function testRoundTripJsonSerialisable(Class, tests) { + const validate = compile(Class.jsonSchema); for (const test of tests) { const original = new Class(...test); const serialised = JSON.stringify(original); - const reconstructed = Class.fromJSON(JSON.parse(serialised)); + const jsonObject = JSON.parse(serialised); + const reconstructed = Class.fromJSON(jsonObject); assert.deepEqual(reconstructed, original, JSON.stringify(test)); + if (!validate(jsonObject)) { + throw validate.errors; + } } } diff --git a/test/controller/Controller.js b/test/controller/Controller.js index f2f6a3a5..ae45a11b 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -1,7 +1,7 @@ "use strict"; const path = require("node:path"); const assert = require("assert").strict; -const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller"); +const { Controller, HostRecord, InstanceRecord, UserRecord } = require("@clusterio/controller"); const { EventEmitter } = require("stream"); const { @@ -39,7 +39,7 @@ describe("controller/src/Controller", function() { mockInstanceConfig = new MockInstanceConfig(new Map([ ["instance.id", 100], ["instance.name", "test"], ["factorio.settings", []], ])); - await controller.instanceCreate(mockInstanceConfig); + await controller.instances.createInstance(mockInstanceConfig); }); function check(list, ip, present = true) { if (present === false) { @@ -191,53 +191,9 @@ describe("controller/src/Controller", function() { describe(".instances", function() { it("should set the dirty flag if the config is updated", function() { - controller.instances.dirty = false; + controller.instances.records.dirty = false; mockInstanceConfig.set("instance.assigned_host", null); - assert(controller.instances.dirty === true, "dirty flag was not set"); - }); - }); - describe(".finaliseHosts()", function() { - it("resets the connected state to false", function() { - const hostInfo = new HostInfo(1, "", "", new Map(), true); - Controller.finaliseHosts(hostInfo); - assert.equal(hostInfo.connected, false); - assert(hostInfo.updatedAtMs > 0, "updatedAtMs not incremented"); - }); - it("does not increment updatedAtMs when there are no changes", function() { - const hostInfo = new HostInfo(1, "", "", new Map(), false); - Controller.finaliseHosts(hostInfo); - assert.equal(hostInfo.connected, false); - assert(hostInfo.updatedAtMs === 0, "updatedAtMs incremented"); - }); - }); - describe(".finaliseInstances()", function() { - it("resets the stats for assigned instances", function() { - const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 }); - const instanceInfo = new InstanceInfo(instanceConfig, "running"); - Controller.finaliseInstances(instanceInfo); - assert.equal(instanceInfo.status, "unknown"); - assert(instanceInfo.updatedAtMs > 0, "updatedAtMs not incremented"); - }); - it("resets the status for unassigned instances", function() { - const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null }); - const instanceInfo = new InstanceInfo(instanceConfig, "running"); - Controller.finaliseInstances(instanceInfo); - assert.equal(instanceInfo.status, "unassigned"); - assert(instanceInfo.updatedAtMs > 0, "updatedAtMs not incremented"); - }); - it("does not update updatedAtMs when there are no changes (assigned)", function() { - const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 }); - const instanceInfo = new InstanceInfo(instanceConfig, "unknown"); - Controller.finaliseInstances(instanceInfo); - assert.equal(instanceInfo.status, "unknown"); - assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented"); - }); - it("does not update updatedAtMs when there are no changes (unassigned)", function() { - const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null }); - const instanceInfo = new InstanceInfo(instanceConfig, "unassigned"); - Controller.finaliseInstances(instanceInfo); - assert.equal(instanceInfo.status, "unassigned"); - assert(instanceInfo.updatedAtMs === 0, "updatedAtMs incremented"); + assert(controller.instances.records.dirty === true, "dirty flag was not set"); }); }); describe(".checkRestartRequired()", function() { @@ -297,8 +253,117 @@ describe("controller/src/Controller", function() { }); }); }); + describe("finalise", function() { + describe(".finaliseHosts()", function() { + it("resets the connected state to false", function() { + const hostInfo = new HostRecord(1, "", "", new Map(), true); + Controller.finaliseHosts(hostInfo); + assert.equal(hostInfo.connected, false); + assert(hostInfo.updatedAtMs > 0, "updatedAtMs not incremented"); + }); + it("does not increment updatedAtMs when there are no changes", function() { + const hostInfo = new HostRecord(1, "", "", new Map(), false); + Controller.finaliseHosts(hostInfo); + assert.equal(hostInfo.connected, false); + assert(hostInfo.updatedAtMs === 0, "updatedAtMs incremented"); + }); + }); + describe(".finaliseInstances()", function() { + it("resets the stats for assigned instances", function() { + const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 }); + const instanceRecord = new InstanceRecord(instanceConfig, "running"); + Controller.finaliseInstances(instanceRecord); + assert.equal(instanceRecord.status, "unknown"); + assert(instanceRecord.updatedAtMs > 0, "updatedAtMs not incremented"); + }); + it("resets the status for unassigned instances", function() { + const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null }); + const instanceRecord = new InstanceRecord(instanceConfig, "running"); + Controller.finaliseInstances(instanceRecord); + assert.equal(instanceRecord.status, "unassigned"); + assert(instanceRecord.updatedAtMs > 0, "updatedAtMs not incremented"); + }); + it("does not update updatedAtMs when there are no changes (assigned)", function() { + const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": 1 }); + const instanceRecord = new InstanceRecord(instanceConfig, "unknown"); + Controller.finaliseInstances(instanceRecord); + assert.equal(instanceRecord.status, "unknown"); + assert(instanceRecord.updatedAtMs === 0, "updatedAtMs incremented"); + }); + it("does not update updatedAtMs when there are no changes (unassigned)", function() { + const instanceConfig = new InstanceConfig("controller", { "instance.assigned_host": null }); + const instanceRecord = new InstanceRecord(instanceConfig, "unassigned"); + Controller.finaliseInstances(instanceRecord); + assert.equal(instanceRecord.status, "unassigned"); + assert(instanceRecord.updatedAtMs === 0, "updatedAtMs incremented"); + }); + }); + describe(".finaliseUsers()", function() { + it("returns the user when there is no existing user", function() { + const user = new UserRecord(0, "TestUser", new Set([1])); + user.playerStats.onlineTimeMs = 100; + + const users = new Map(); + const result = Controller.finaliseUsers(user, users); + assert.equal(users.size, 0); + assert.equal(result, user); + }); + it("does not merge users with different ids", function() { + const existingUser = new UserRecord(0, "UserOne", new Set([1])); + existingUser.playerStats.onlineTimeMs = 100; + + const otherUser = new UserRecord(0, "UserTwo", new Set([2])); + otherUser.playerStats.onlineTimeMs = 50; + + const users = new Map([[existingUser.id, existingUser]]); + const result = Controller.finaliseUsers(otherUser, users); + assert.equal(users.size, 1); + assert.equal(result, otherUser); + assert(!result.roleIds.has(1), "users were merged"); + }); + it("merges into existing user when onlineTimeMs is lower", function() { + const existingUser = new UserRecord(0, "TestUser", new Set([1])); + existingUser.playerStats.onlineTimeMs = 100; + + const user = new UserRecord(0, "TestUser", new Set([2])); + user.playerStats.onlineTimeMs = 50; + + const users = new Map([[existingUser.id, existingUser]]); + const result = Controller.finaliseUsers(user, users); + assert.equal(users.size, 1); + assert.equal(result, existingUser); + assert(result.roleIds.has(2), "other user not merged"); + }); + it("merges into existing user when onlineTimeMs is equal", function() { + const existingUser = new UserRecord(0, "TestUser", new Set([1])); + existingUser.playerStats.onlineTimeMs = 100; + + const user = new UserRecord(0, "TestUser", new Set([2])); + user.playerStats.onlineTimeMs = 100; + + const users = new Map([[existingUser.id, existingUser]]); + const result = Controller.finaliseUsers(user, users); + assert.equal(users.size, 1); + assert.equal(result, existingUser); + assert(result.roleIds.has(2), "other user not merged"); + }); + it("merges existing user into new user when onlineTimeMs is higher", function() { + const existingUser = new UserRecord(0, "TestUser", new Set([1])); + existingUser.playerStats.onlineTimeMs = 50; + + const user = new UserRecord(0, "TestUser", new Set([2])); + user.playerStats.onlineTimeMs = 100; + + const users = new Map([[existingUser.id, existingUser]]); + const result = Controller.finaliseUsers(user, users); + assert.equal(users.size, 1); + assert.equal(result, user); + assert(result.roleIds.has(1), "existing user not merged"); + }); + }); + }); describe("migrations", function() { - describe("SystemInfo", function() { + describe(".migrateSystems()", function() { it("migrates 'canRestart' from undefined to false", function() { // Alpha 17 const result = Controller.migrateSystems([ {}, { canRestart: true }, { canRestart: false }, @@ -350,7 +415,7 @@ describe("controller/src/Controller", function() { ]); }); }); - describe("HostInfo", function() { + describe(".migrateHosts()", function() { it("migrates to the new format", function() { // Alpha 19 const result = Controller.migrateHosts([ ["", "foo"], ["", "bar"], ["", "baz"], @@ -360,7 +425,7 @@ describe("controller/src/Controller", function() { ]); }); it("does nothing for upto date data", function() { - const hostInfo = new HostInfo(1, "", "", new Map()); + const hostInfo = new HostRecord(1, "", "", new Map()); const jsonString = JSON.stringify(hostInfo); const hostInfoJson = JSON.parse(jsonString); const hostInfoJsonCopy = JSON.parse(jsonString); @@ -372,7 +437,7 @@ describe("controller/src/Controller", function() { ]); }); }); - describe("InstanceInfo", function() { + describe(".migrateInstances()", function() { it("migrates to the new format", function() { // Alpha 14 const result = Controller.migrateInstances([ { name: "foo" }, { name: "bar" }, { name: "baz" }, @@ -385,19 +450,19 @@ describe("controller/src/Controller", function() { }); it("does nothing for upto date data", function() { const instanceConfig = new InstanceConfig("controller"); - const instanceInfo = new InstanceInfo(instanceConfig, "unknown"); - const jsonString = JSON.stringify(instanceInfo); - const instanceInfoJson = JSON.parse(jsonString); - const instanceInfoJsonCopy = JSON.parse(jsonString); + const instanceRecord = new InstanceRecord(instanceConfig, "unknown"); + const jsonString = JSON.stringify(instanceRecord); + const instanceRecordJson = JSON.parse(jsonString); + const instanceRecordJsonCopy = JSON.parse(jsonString); const result = Controller.migrateInstances([ - instanceInfoJson, instanceInfoJson, instanceInfoJson, + instanceRecordJson, instanceRecordJson, instanceRecordJson, ]); assert.deepEqual(result, [ - instanceInfoJsonCopy, instanceInfoJsonCopy, instanceInfoJsonCopy, + instanceRecordJsonCopy, instanceRecordJsonCopy, instanceRecordJsonCopy, ]); }); }); - describe("ModPack", function() { + describe(".migrateModPacks()", function() { it("migrates builtin mod versions to X.Y.Z", function() { const modPacks = Controller.migrateModPacks([{ id: 0, @@ -439,7 +504,7 @@ describe("controller/src/Controller", function() { ]); }); }); - describe("Role", function() { + describe(".migrateRoles()", function() { it("migrates renamed permissions", function() { // Alpha 17 const result = Controller.migrateRoles([ { permissions: ["foo", "bar", "core.instance.save.list.subscribe"] }, diff --git a/test/controller/ControllerUser.js b/test/controller/ControllerUser.js index cbfa8bd4..12f789c3 100644 --- a/test/controller/ControllerUser.js +++ b/test/controller/ControllerUser.js @@ -1,36 +1,36 @@ "use strict"; const assert = require("assert").strict; -const { ControllerUser } = require("@clusterio/controller"); const lib = require("@clusterio/lib"); +const { User } = require("@clusterio/controller"); -describe("controller/src/ControllerUser", function() { - describe("class ControllerUser", function() { +describe("controller/src/User", function() { + describe("class User", function() { const roles = new Map([ [1, new lib.Role(1, "a", "a role", new Set(["core.admin"]))], [2, new lib.Role(2, "b", "b role", new Set(["user-test"]))], ]); it("should round trip serialize", function() { - function test_roundtrip(serialized) { - let user = ControllerUser.fromJSON(serialized, roles); - let user_serialized = user.toJSON(true); + function testRoundTrip(serialized) { + let user = User.fromJSON(serialized, {}, roles); + let user_serialized = user.toJSON(); assert.deepEqual(user_serialized, serialized); - let user_deserialized = ControllerUser.fromJSON(user_serialized, roles); + let user_deserialized = User.fromJSON(user_serialized, {}, roles); assert.deepEqual(user_deserialized, user); } - test_roundtrip({ name: "admin", roles: [1] }); - test_roundtrip({ name: "user", roles: [2], token_valid_after: 12345 }); - test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); - test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); - test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); + testRoundTrip({ name: "admin", roles: [1] }); + testRoundTrip({ name: "user", roles: [2], token_valid_after: 12345 }); + testRoundTrip({ name: "user", is_admin: true, is_whitelisted: true }); + testRoundTrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); + testRoundTrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); }); describe(".checkPermission()", function() { it("should correctly resolve permissions", function() { lib.definePermission({ name: "user-test", title: "Test", description: "User Test" }); - let a = ControllerUser.fromJSON({ name: "admin", roles: [1] }, roles); - let b = ControllerUser.fromJSON({ name: "user", roles: [2] }, roles); - let c = ControllerUser.fromJSON({ name: "null", roles: [] }, roles); + let a = User.fromJSON({ name: "admin", roles: [1] }, {}, roles); + let b = User.fromJSON({ name: "user", roles: [2] }, {}, roles); + let c = User.fromJSON({ name: "null", roles: [] }, {}, roles); a.checkPermission("core.control.connect"); assert.throws(() => b.checkPermission("core.control.connect"), new Error("Permission denied")); diff --git a/test/controller/HostRecord.test.js b/test/controller/HostRecord.test.js new file mode 100644 index 00000000..60ca74de --- /dev/null +++ b/test/controller/HostRecord.test.js @@ -0,0 +1,204 @@ +"use strict"; +const assert = require("assert").strict; +const { HostDetails } = require("@clusterio/lib"); +const { HostRecord } = require("@clusterio/controller"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); + +describe("controller/HostRecord", function () { + const samplePlugins = new Map([ + ["pluginA", "1.0.0"], + ["pluginB", "2.3.4"], + ]); + + it("should be round trip serialisable", function () { + testRoundTripJsonSerialisable(HostRecord, testMatrix( + [1], // id + ["TestHost"], // name + ["1.0.0"], // version + [samplePlugins, new Map()], // plugins + [true, false], // connected + [undefined, "10.0.0.1"], // remoteAddress + [undefined, "203.0.113.1"], // publicAddress + [undefined, 123456], // tokenValidAfter + [undefined, 999999], // updatedAtMs + [undefined, true], // isDeleted + )); + }); + + describe("constructor", function () { + it("should set defaults correctly", function () { + const record = new HostRecord(1, "TestHost", "1.0.0", new Map()); + + assert.equal(record.id, 1); + assert.equal(record.name, "TestHost"); + assert.equal(record.version, "1.0.0"); + assert.deepEqual(record.plugins, new Map()); + assert.equal(record.connected, false); + assert.equal(record.remoteAddress, ""); + assert.equal(record.publicAddress, ""); + assert.equal(record.tokenValidAfter, 0); + assert.equal(record.updatedAtMs, 0); + assert.equal(record.isDeleted, false); + }); + + it("should accept all constructor parameters", function () { + const record = new HostRecord( + 42, "FullHost", "2.0.0", samplePlugins, + true, "10.1.1.1", "198.51.100.5", + 123, 999, true + ); + + assert.equal(record.id, 42); + assert.equal(record.name, "FullHost"); + assert.equal(record.version, "2.0.0"); + assert.deepEqual(record.plugins, samplePlugins); + assert.equal(record.connected, true); + assert.equal(record.remoteAddress, "10.1.1.1"); + assert.equal(record.publicAddress, "198.51.100.5"); + assert.equal(record.tokenValidAfter, 123); + assert.equal(record.updatedAtMs, 999); + assert.equal(record.isDeleted, true); + }); + }); + + describe(".toJSON()", function () { + it("should only include required fields when optional fields are not given", function () { + const record = new HostRecord(1, "TestHost", "1.0.0", samplePlugins); + + assert.deepEqual(record.toJSON(), { + id: 1, + name: "TestHost", + version: "1.0.0", + plugins: Object.fromEntries(samplePlugins), + }); + }); + + it("should include all optional fields when set", function () { + const record = new HostRecord( + 1, "TestHost", "1.0.0", samplePlugins, + true, "10.0.0.1", "203.0.113.1", + 100, 500, true + ); + + assert.deepEqual(record.toJSON(), { + id: 1, + name: "TestHost", + version: "1.0.0", + plugins: Object.fromEntries(samplePlugins), + connected: true, + remote_address: "10.0.0.1", + public_address: "203.0.113.1", + token_valid_after: 100, + updated_at_ms: 500, + is_deleted: true, + }); + }); + + it("should omit optional fields when equal to defaults", function () { + const record = new HostRecord( + 1, "TestHost", "1.0.0", samplePlugins, + false, "", "", + 0, 0, false + ); + + assert.deepEqual(record.toJSON(), { + id: 1, + name: "TestHost", + version: "1.0.0", + plugins: Object.fromEntries(samplePlugins), + }); + }); + }); + + describe("static fromJSON()", function () { + it("should construct minimal object", function () { + const record = HostRecord.fromJSON({ + id: 7, + name: "JSONHost", + version: "1.1.0", + plugins: { pluginX: "0.1.0" }, + }); + + assert.equal(record.id, 7); + assert.equal(record.name, "JSONHost"); + assert.equal(record.version, "1.1.0"); + assert.deepEqual(record.plugins, new Map([["pluginX", "0.1.0"]])); + assert.equal(record.connected, false); + assert.equal(record.remoteAddress, ""); + assert.equal(record.publicAddress, ""); + assert.equal(record.tokenValidAfter, 0); + assert.equal(record.updatedAtMs, 0); + assert.equal(record.isDeleted, false); + }); + + it("should preserve optional fields exactly when provided", function () { + const record = HostRecord.fromJSON({ + id: 7, + name: "JSONHost", + version: "1.1.0", + plugins: { pluginX: "0.1.0" }, + connected: true, + remote_address: "10.1.1.1", + public_address: "198.51.100.10", + token_valid_after: 200, + updated_at_ms: 1000, + is_deleted: true, + }); + + assert.equal(record.id, 7); + assert.equal(record.name, "JSONHost"); + assert.equal(record.version, "1.1.0"); + assert.deepEqual(record.plugins, new Map([["pluginX", "0.1.0"]])); + assert.equal(record.connected, true); + assert.equal(record.remoteAddress, "10.1.1.1"); + assert.equal(record.publicAddress, "198.51.100.10"); + assert.equal(record.tokenValidAfter, 200); + assert.equal(record.updatedAtMs, 1000); + assert.equal(record.isDeleted, true); + }); + }); + + describe(".toHostDetails()", function () { + it("should convert HostRecord to HostDetails correctly", function () { + const record = new HostRecord( + 1, "TestHost", "1.0.0", samplePlugins, + true, "10.0.0.1", "203.0.113.1", + 123, 456, true + ); + + const details = record.toHostDetails(); + + assert(details instanceof HostDetails); + assert.equal(details.version, record.version); + assert.equal(details.name, record.name); + assert.equal(details.id, record.id); + assert.equal(details.connected, record.connected); + assert.equal(details.remoteAddress, record.remoteAddress); + assert.equal(details.publicAddress, record.publicAddress); + assert.equal(details.tokenValidAfter, record.tokenValidAfter); + assert.equal(details.updatedAtMs, record.updatedAtMs); + assert.equal(details.isDeleted, record.isDeleted); + }); + }); + + describe("static fromHostDetails()", function () { + it("should create HostRecord from HostDetails", function () { + const details = new HostDetails("1.0.0", "TestHost", 1, true); + + const record = HostRecord.fromHostDetails(details, samplePlugins); + + assert(record instanceof HostRecord); + assert.equal(record.id, details.id); + assert.equal(record.name, details.name); + assert.equal(record.version, details.version); + assert.deepEqual(record.plugins, samplePlugins); + assert.equal(record.connected, details.connected); + assert.equal(record.remoteAddress, details.remoteAddress); + assert.equal(record.publicAddress, details.publicAddress); + assert.equal(record.tokenValidAfter, details.tokenValidAfter); + assert.equal(record.updatedAtMs, details.updatedAtMs); + assert.equal(record.isDeleted, details.isDeleted); + }); + }); +}); diff --git a/test/controller/InstanceInfo.js b/test/controller/InstanceInfo.js deleted file mode 100644 index fda15835..00000000 --- a/test/controller/InstanceInfo.js +++ /dev/null @@ -1,36 +0,0 @@ -"use strict"; -const assert = require("assert").strict; - -const { InstanceInfo } = require("@clusterio/controller"); -const lib = require("@clusterio/lib"); - - -describe("controller/InstanceInfo", function() { - describe("class InstanceInfo", function() { - it("should round trip serialize", function() { - const validate = lib.compile(InstanceInfo.jsonSchema); - function check(info) { - const json = JSON.parse(JSON.stringify(info)); - if (!validate(json)) { - throw validate.errors; - } - assert.deepEqual(InstanceInfo.fromJSON(json, "controller"), info); - } - - const config = new lib.InstanceConfig("controller").toJSON(); - - check(InstanceInfo.fromJSON({ config, status: "stopped" }, "controller")); - check(InstanceInfo.fromJSON({ config, status: "running" }, "controller")); - check(InstanceInfo.fromJSON({ config, status: "running", gamePort: 34197 }, "controller")); - check(InstanceInfo.fromJSON({ config, status: "running", updatedAtMs: Date.now() }, "controller")); - - // All at once - check(InstanceInfo.fromJSON({ - config, - status: "unknown", - gamePort: 10000, - updateAt: 20000, - }, "controller")); - }); - }); -}); diff --git a/test/controller/InstanceManager.test.js b/test/controller/InstanceManager.test.js new file mode 100644 index 00000000..312cb402 --- /dev/null +++ b/test/controller/InstanceManager.test.js @@ -0,0 +1,358 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +const { Controller, InstanceRecord, InstanceManager } = require("@clusterio/controller"); + +describe("controller/InstanceManager", function () { + /** @type {Controller} */ + let controller; + /** @type {InstanceManager} */ + let instances; + /** @type {InstanceRecord} */ + let instance; + + beforeEach(function () { + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.name": "TestController", + }); + const instanceConfig = new lib.InstanceConfig("controller", { + "instance.id": 1, + "instance.name": "Test", + }); + + const datastoreInstances = new lib.SubscribableDatastore(); + datastoreInstances.set(new InstanceRecord(instanceConfig, "unassigned")); + + controller = new Controller(lib.logger, [], controllerConfig, + undefined, undefined, undefined, undefined, datastoreInstances); + instances = controller.instances; + instance = instances.getMutable(1); + }); + + describe("constructor()", function () { + it("should call _notifyConfigFieldChanged when config changes", async function () { + let called = false; + + instances._notifyConfigFieldChanged = async ( + inst, field, curr, prev + ) => { + called = true; + assert.equal(inst.id, 1); + assert.equal(field, "instance.name"); + assert.equal(prev, "Test"); + assert.equal(curr, "Updated"); + }; + + instance.config.set("instance.name", "Updated"); + + assert.equal(called, true); + }); + + it("should update datastore when config changes", function () { + let setCalled = false; + + const originalSet = instances.records.set.bind(instances.records); + instances.records.set = function (value) { + setCalled = true; + return originalSet(value); + }; + + instance.config.set("instance.name", "Changed"); + + assert.equal(setCalled, true); + }); + }); + + describe(".has() / .get() / .getMutable()", function () { + it("should return instance when exists", async function () { + assert.equal(instances.has(instance.id), true); + assert.equal(instances.get(instance.id), instance); + assert.equal(instances.getMutable(instance.id), instance); + }); + + it("should return undefined when missing", function () { + assert.equal(instances.has(999), false); + assert.equal(instances.get(999), undefined); + }); + }); + + describe(".values() / .valuesMutable()", function () { + it("should iterate all instances", async function () { + const config1 = new lib.InstanceConfig("controller", { + "instance.id": 2, + "instance.name": "A", + }); + const config2 = new lib.InstanceConfig("controller", { + "instance.id": 3, + "instance.name": "B", + }); + + const i1 = await instances.createInstance(config1); + const i2 = await instances.createInstance(config2); + + assert.deepEqual(Array.from(instances.values()), [instance, i1, i2]); + assert.deepEqual(Array.from(instances.valuesMutable()), [instance, i1, i2]); + }); + }); + + describe(".getForRequest()", function () { + it("should return instance when exists", async function () { + assert.equal(instances.getForRequest(1), instance); + }); + + it("should throw when missing", function () { + assert.throws(() => instances.getForRequest(999), lib.RequestError); + }); + }); + + describe(".createInstance()", function () { + it("should create new instance with id 2", async function () { + let notified = false; + instances._notifyStatusChanged = async () => { notified = true; }; + + const config = new lib.InstanceConfig("controller", { + "instance.id": 2, + "instance.name": "Second", + }); + + const created = await instances.createInstance(config); + + assert.equal(created.id, 2); + assert(instances.get(2), created); + assert.equal(notified, true); + }); + + it("should call notify on config change", async function () { + let notified = false; + instances._notifyConfigFieldChanged = async () => { notified = true; }; + + const config = new lib.InstanceConfig("controller", { + "instance.id": 2, + "instance.name": "Second", + }); + + const created = await instances.createInstance(config); + + created.config.set("instance.name", "Updated"); + + assert.equal(created.id, 2); + assert.equal(instances.get(2), created); + assert.equal(notified, true); + }); + + it("should throw if id already exists", async function () { + const config = new lib.InstanceConfig("controller", { + "instance.id": 1, + "instance.name": "Duplicate", + }); + + await assert.rejects( + () => instances.createInstance(config), + lib.RequestError + ); + }); + + it("should suppress changes when flag set", async function () { + let notified = false; + instances._notifyStatusChanged = async () => { notified = true; }; + + const config = new lib.InstanceConfig("controller", { + "instance.id": 2, + "instance.name": "Suppressed", + }); + + const created = await instances.createInstance(config, true); + + assert.equal(instances.get(2), undefined); + assert.equal(notified, false); + }); + }); + + describe(".assignInstance()", function () { + it("should throw if host not connected", async function () { + await assert.rejects( + () => instances.assignInstance(1, 5), + lib.RequestError + ); + }); + + it("should do nothing if the host would not change", async function () { + let assignRequest; + controller.wsServer.hostConnections.set(5, { + send: async (request) => { + assignRequest = request; + }, + connector: { closing: false }, + }); + + instance.config.set("instance.assigned_host", 5); + + await instances.assignInstance(1, 5); + assert.equal(assignRequest, undefined); + }); + + it("should not unassign if new host is not connected", async function () { + let unassignRequest; + controller.wsServer.hostConnections.set(5, { + send: async (request) => { + unassignRequest = request; + }, + connector: { closing: false }, + }); + + instance.config.set("instance.assigned_host", 5); + + await assert.rejects( + () => instances.assignInstance(1, 6), + lib.RequestError + ); + + assert.equal(unassignRequest, undefined); + }); + + it("should assign to connected host", async function () { + let assignRequest; + controller.wsServer.hostConnections.set(5, { + send: async (request) => { + assignRequest = request; + }, + connector: { closing: false }, + }); + + await instances.assignInstance(1, 5); + + assert.equal(instance.config.get("instance.assigned_host"), 5); + assert(assignRequest instanceof lib.InstanceAssignInternalRequest); + assert.equal(assignRequest.instanceId, 1); + }); + + it("should not fail when host has id of 0", async function () { + let lastEvent; + controller.wsServer.hostConnections.set(0, { + send: async (request) => { + lastEvent = request; + }, + connector: { closing: false }, + }); + + await instances.assignInstance(1, 0); + + assert.equal(instance.config.get("instance.assigned_host"), 0); + assert(lastEvent instanceof lib.InstanceAssignInternalRequest); + assert.equal(lastEvent.instanceId, 1); + + await instances.assignInstance(1, null); + + assert.equal(instance.config.get("instance.assigned_host"), null); + assert(lastEvent instanceof lib.InstanceUnassignInternalRequest); + assert.equal(lastEvent.instanceId, 1); + }); + + it("should unassign old host if connected", async function () { + let unassignRequest; + controller.wsServer.hostConnections.set(5, { + send: async (request) => { + unassignRequest = request; + }, + connector: { closing: false }, + }); + + let assignRequest; + controller.wsServer.hostConnections.set(6, { + send: async (request) => { + assignRequest = request; + }, + connector: { closing: false }, + }); + + instance.config.set("instance.assigned_host", 5); + await instances.assignInstance(1, 6); + + assert(unassignRequest instanceof lib.InstanceUnassignInternalRequest); + assert.equal(unassignRequest.instanceId, 1); + assert(assignRequest instanceof lib.InstanceAssignInternalRequest); + assert.equal(assignRequest.instanceId, 1); + }); + + it("should not unassign if old host closing", async function () { + let unassignRequest; + controller.wsServer.hostConnections.set(5, { + send: async (request) => { + unassignRequest = request; + }, + connector: { closing: true }, + }); + + let assignRequest; + controller.wsServer.hostConnections.set(6, { + send: async (request) => { + assignRequest = request; + }, + connector: { closing: false }, + }); + + instance.config.set("instance.assigned_host", 5); + await instances.assignInstance(1, 6); + + assert.equal(unassignRequest, undefined); + assert(assignRequest instanceof lib.InstanceAssignInternalRequest); + assert.equal(assignRequest.instanceId, 1); + }); + }); + + describe(".unassignInstance()", function () { + it("should unassign an instance", async function () { + controller.wsServer.hostConnections.set(5, { + send: async () => {}, + connector: { closing: true }, + }); + + await instances.assignInstance(instance.id, 5); + await instances.unassignInstance(instance.id); + assert.equal(instance.config.get("instance.assigned_host"), null); + assert.equal(instance.status, "unassigned"); + }); + it("should do nothing if the host would not change", async function () { + instance.config.set("instance.assigned_host", null); + + let fieldChanged; + instance.config.on("fieldChanged", name => { + fieldChanged = name; + }); + + await instances.unassignInstance(1); + assert.equal(fieldChanged, undefined); + }); + }); + + describe(".deleteInstance()", function () { + it("should delete and notify", async function () { + let notified = false; + instances._notifyStatusChanged = async () => { notified = true; }; + + await instances.deleteInstance(1); + + assert.equal(instances.get(1), undefined); + assert.equal(notified, true); + }); + + it("should send delete to host if assigned", async function () { + let sentRequest; + controller.sendTo = async (_target, request) => { + sentRequest = request; + }; + + controller.wsServer.hostConnections.set(5, { + send: async () => {}, + connector: { closing: false }, + }); + + await instances.assignInstance(1, 5); + await instances.deleteInstance(1); + + assert(sentRequest instanceof lib.InstanceDeleteInternalRequest); + assert.equal(sentRequest.instanceId, 1); + }); + }); +}); diff --git a/test/controller/InstanceRecord.test.js b/test/controller/InstanceRecord.test.js new file mode 100644 index 00000000..c3c169c5 --- /dev/null +++ b/test/controller/InstanceRecord.test.js @@ -0,0 +1,197 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); +const { InstanceRecord } = require("@clusterio/controller"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); + +describe("controller/InstanceRecord", function () { + /** @type {lib.InstanceConfig} */ + let defaultConfig; + + before(function() { + // This is needed to pick up any plugins that are loaded + defaultConfig = new lib.InstanceConfig("controller", { + "instance.id": 1, + "instance.name": "TestInstance", + "instance.assigned_host": 1, + "instance.exclude_from_start_all": false, + }); + }); + + it("should be round trip serialisable", function () { + testRoundTripJsonSerialisable(InstanceRecord, testMatrix( + [defaultConfig], // config + ["running", "stopped"], // status + [undefined, 34197], // gamePort + [undefined, "1.0.0"], // factorioVersion + [undefined, 1234], // startedAtMs + [undefined, 5678], // updatedAtMs + )); + }); + + describe("constructor", function () { + it("should set defaults correctly", function () { + const record = new InstanceRecord(defaultConfig, "running"); + + assert.equal(record.config, defaultConfig); + assert.equal(record.status, "running"); + assert.equal(record.gamePort, undefined); + assert.equal(record.factorioVersion, undefined); + assert.equal(record.startedAtMs, 0); + assert.equal(record.updatedAtMs, 0); + }); + + it("should accept all constructor parameters", function () { + const record = new InstanceRecord( + defaultConfig, "starting", + 34197, "1.0.0", + 100, 200 + ); + + assert.equal(record.status, "starting"); + assert.equal(record.gamePort, 34197); + assert.equal(record.factorioVersion, "1.0.0"); + assert.equal(record.startedAtMs, 100); + assert.equal(record.updatedAtMs, 200); + }); + }); + + describe(".toJSON()", function () { + it("should include required fields when optional fields are not given", function () { + const record = new InstanceRecord(defaultConfig, "running"); + + assert.deepEqual(record.toJSON(), { + config: defaultConfig.toJSON(), + status: "running", + }); + }); + + it("should include optional fields when non-default", function () { + const record = new InstanceRecord( + defaultConfig, "running", + 34197, "1.0.0", + 100, 200 + ); + + assert.deepEqual(record.toJSON(), { + config: defaultConfig.toJSON(), + status: "running", + gamePort: 34197, + factorioVersion: "1.0.0", + startedAtMs: 100, + updatedAtMs: 200, + }); + }); + + it("should omit optional fields when equal to defaults", function () { + const record = new InstanceRecord( + defaultConfig, "running", + undefined, undefined, + undefined, undefined + ); + + assert.deepEqual(record.toJSON(), { + config: defaultConfig.toJSON(), + status: "running", + }); + }); + }); + + describe("static fromJSON()", function () { + it("should construct minimal object", function () { + const json = { + config: defaultConfig.toJSON(), + status: "stopped", + }; + + const record = InstanceRecord.fromJSON(json); + + assert.equal(record.status, "stopped"); + assert.equal(record.gamePort, undefined); + assert.equal(record.factorioVersion, undefined); + assert.equal(record.startedAtMs, 0); + assert.equal(record.updatedAtMs, 0); + }); + + it("should preserve optional fields exactly when provided", function () { + const json = { + config: defaultConfig.toJSON(), + status: "stopped", + gamePort: 34197, + factorioVersion: "1.0.0", + startedAtMs: 100, + updatedAtMs: 200, + }; + + const record = InstanceRecord.fromJSON(json); + + assert.equal(record.status, "stopped"); + assert.equal(record.gamePort, 34197); + assert.equal(record.factorioVersion, "1.0.0"); + assert.equal(record.startedAtMs, 100); + assert.equal(record.updatedAtMs, 200); + }); + }); + + describe("toInstanceDetails()", function () { + it("should convert InstanceRecord to lib.InstanceDetails correctly", function () { + const record = new InstanceRecord( + defaultConfig, "running", + 34197, "1.0.0", + 100, 200 + ); + + const details = record.toInstanceDetails(); + + assert(details instanceof lib.InstanceDetails); + assert.equal(details.name, record.config.get("instance.name")); + assert.equal(details.id, record.id); + assert.equal(details.assignedHost, 1); + assert.equal(details.gamePort, 34197); + assert.equal(details.status, "running"); + assert.equal(details.factorioVersion, "1.0.0"); + assert.equal(details.startedAtMs, 100); + assert.equal(details.updatedAtMs, 200); + assert.equal(details.excludeFromStartAll, false); + }); + }); + + describe("static fromInstanceDetails()", function () { + it("should create InstanceRecord from lib.InstanceDetails", function () { + const details = new lib.InstanceDetails( + "TestInstance", 1, 1, 34197, + "running", "1.0.0", + 100, 200, false + ); + + const record = InstanceRecord.fromInstanceDetails(details, defaultConfig); + + assert(record instanceof InstanceRecord); + assert.equal(record.status, "running"); + assert.equal(record.gamePort, 34197); + assert.equal(record.factorioVersion, "1.0.0"); + assert.equal(record.startedAtMs, 100); + assert.equal(record.updatedAtMs, 200); + }); + }); + + describe("get/set .isDeleted", function () { + it("should return true if status is deleted", function () { + const record = new InstanceRecord(defaultConfig, "deleted"); + assert.equal(record.isDeleted, true); + }); + + it("should throw if trying to set isDeleted to false", function () { + const record = new InstanceRecord(defaultConfig, "running"); + assert.throws(() => { record.isDeleted = false; }, /Setting isDeleted to false is not supported/); + }); + + it("should set status to deleted when isDeleted = true", function () { + const record = new InstanceRecord(defaultConfig, "running"); + record.isDeleted = true; + assert.equal(record.status, "deleted"); + assert.equal(record.isDeleted, true); + }); + }); +}); diff --git a/test/controller/User.test.js b/test/controller/User.test.js new file mode 100644 index 00000000..3e5ea4ab --- /dev/null +++ b/test/controller/User.test.js @@ -0,0 +1,156 @@ +"use strict"; +const assert = require("assert").strict; +const { Role, SubscribableDatastore } = require("@clusterio/lib"); +const { User } = require("@clusterio/controller"); + +describe("controller/User", function () { + /** @type {SubscribableDatastore} */ + let users; + /** @type {SubscribableDatastore} */ + let roles; + /** @type {User} */ + let user; + + beforeEach(function () { + users = new SubscribableDatastore(); + roles = new SubscribableDatastore(); + + // Create a role and user used in most tests + roles.set(Role.fromJSON({ id: 1, name: "connectRole", permissions: ["core.control.connect"] })); + user = User.fromJSON({ name: "Alice", roles: [1] }, users, roles); + + users.set(user); + user.updatedAtMs = 1000; // Known low value + }); + + describe("get roles", function () { + it("should return roles from _controllerRoles", function () { + const roleSet = user.roles; + assert.equal(roleSet.size, 1); + assert.equal([...roleSet][0].id, 1); + }); + }); + + describe(".saveRecord() / set", function () { + it("should update updatedAtMs", function () { + const prev = user.updatedAtMs; + user.saveRecord(); + assert(user.updatedAtMs > prev); + }); + }); + + describe(".set()", function () { + it("should update updatedAtMs", function () { + const prev = user.updatedAtMs; + user.set("isAdmin", true); + assert.equal(user.isAdmin, true); + assert(user.updatedAtMs > prev); + }); + }); + + describe(".invalidateToken()", function () { + it("should update tokenValidAfter to current time", function () { + const prevValid = user.tokenValidAfter; + const prevUpdate = user.updatedAtMs; + user.invalidateToken(); + assert(user.tokenValidAfter > prevValid); + assert(user.updatedAtMs > prevUpdate); + }); + }); + + describe(".addRole()", function () { + it("should add role if exists", function () { + roles.set(Role.fromJSON({ id: 2, name: "admin", permissions: ["core.admin"] })); + const prev = user.updatedAtMs; + + user.addRole(2); + assert(user.roleIds.has(2)); + assert(user.updatedAtMs > prev); + }); + + it("should throw if role does not exist", function () { + assert.throws(() => user.addRole(999), /role 999 does not exist/); + }); + }); + + describe(".removeRole()", function () { + it("should remove existing role", function () { + const prev = user.updatedAtMs; + const result = user.removeRole(1); + assert(result); + assert(!user.roleIds.has(1)); + assert(user.updatedAtMs > prev); + }); + + it("should return false if role was not assigned", function () { + const prev = user.updatedAtMs; + const result = user.removeRole(999); + assert.equal(result, false); + assert(user.updatedAtMs === prev); // unchanged + }); + }); + + describe(".notifyJoin() / .notifyLeave()", function () { + it("should add instance on notifyJoin", function () { + const prev = user.updatedAtMs; + user.notifyJoin(10); + assert(user.instances.has(10)); + assert(user.updatedAtMs > prev); + }); + + it("should remove instance on notifyLeave", function () { + user.notifyJoin(10); + user.updatedAtMs = 1000; // Known low value + user.notifyLeave(10); + assert(!user.instances.has(10)); + assert(user.updatedAtMs > 1000); + }); + }); + + describe(".clearInstanceStats()", function () { + it("should remove instanceStats and recalculate playerStats", function () { + const instanceId = 5; + const userWithStats = User.fromJSON({ + name: "Bob", + instanceStats: [[instanceId, { join_count: 1 }]], + }, users, roles); + + users.set(userWithStats); + userWithStats.updatedAtMs = 1000; // Known low value + + userWithStats.clearInstanceStats(instanceId); + assert(!userWithStats.instanceStats.has(instanceId)); + assert(userWithStats.updatedAtMs > 1000); + }); + }); + + describe(".checkPermission()", function () { + it("should allow if role has core.control.connect", function () { + assert.doesNotThrow(() => user.checkPermission("core.control.connect")); + }); + + it("should allow if role has core.admin", function () { + roles.set(Role.fromJSON({ id: 2, name: "admin", permissions: ["core.admin"] })); + const adminUser = User.fromJSON({ name: "Charlie", roles: [2] }, users, roles); + assert.doesNotThrow(() => adminUser.checkPermission("core.admin")); + }); + + it("should not error if user has invalid role", function () { + user.roleIds = { + [Symbol.iterator]: function* () { + // We need the invalid id (99) to be before the valid id (1) + for (const roleId of [99, 1]) { yield roleId; } + }, + }; + assert.doesNotThrow(() => user.checkPermission("core.control.connect")); + }); + + it("should throw if permission not granted", function () { + assert.throws(() => user.checkPermission("core.admin"), /Permission denied/); + }); + + it("should throw if permission does not exist", function () { + assert.throws(() => user.checkPermission("nonexistent.permission"), /does not exist/); + }); + }); +}); diff --git a/test/controller/UserManager.js b/test/controller/UserManager.js deleted file mode 100644 index da01ef92..00000000 --- a/test/controller/UserManager.js +++ /dev/null @@ -1,121 +0,0 @@ -"use strict"; -const fs = require("fs-extra"); -const assert = require("assert").strict; -const { PlayerStats } = require("@clusterio/lib"); -const { ControllerUser, UserManager } = require("@clusterio/controller"); - -describe("controller/src/UserManager", function() { - describe("class ControllerUser", function() { - const userManager = new UserManager({}); - it("should track online users", function() { - let user = ControllerUser.fromJSON({ name: "admin", roles: [1] }); - userManager.users.set(user.id, user); - assert(!userManager.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set()); - - userManager.notifyJoin(user, 12); - assert(userManager.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([12])); - - userManager.notifyJoin(user, 8); - assert(userManager.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([12, 8])); - - userManager.notifyLeave(user, 11); - assert(userManager.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([12, 8])); - - userManager.notifyLeave(user, 12); - assert(userManager.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set([8])); - - userManager.notifyLeave(user, 8); - assert(!userManager.onlineUsers.has(user)); - assert.deepEqual(user.instances, new Set()); - }); - describe(".clearStatsOfInstance()", function() { - it("should remove the instance stats from existing users", function() { - let user1 = ControllerUser.fromJSON({ name: "admin", roles: [1] }); - let user2 = ControllerUser.fromJSON({ name: "player", roles: [1] }); - userManager.users.set(user1.id, user1); - userManager.users.set(user2.id, user2); - user1.instanceStats.set(10, new PlayerStats({ join_count: 1 })); - user1.instanceStats.set(11, new PlayerStats({ join_count: 6 })); - user1.recalculatePlayerStats(); - userManager.notifyJoin(user1, 11); - user2.instanceStats.set(10, new PlayerStats({ join_count: 3 })); - user2.recalculatePlayerStats(); - assert.equal(user1.playerStats.joinCount, 7); - assert.equal(user2.playerStats.joinCount, 3); - assert(userManager.onlineUsers.has(user1)); - - userManager.clearStatsOfInstance(11); - - assert.equal(user1.playerStats.joinCount, 1); - assert(!userManager.onlineUsers.has(user1)); - assert.equal(user2.playerStats.joinCount, 3); - }); - }); - describe(".load()", function() { - it("should merge users on load", async function() { - // Create a user db file containing duplicate user ids - const names = ["user1", "User1", "user3"]; - names.forEach(name => { - const user = ControllerUser.fromJSON({ name: name, roles: [1] }); - userManager.users.set(user.name, user); - }); - - // Setup the player stats - const user1Before = userManager.users.get("user1"); - user1Before.instanceStats.set(9, new PlayerStats({ join_count: 3 })); - user1Before.instanceStats.set(10, new PlayerStats({ join_count: 5 })); - user1Before.banReason = "ban reason here"; - user1Before.isWhitelisted = true; - user1Before.isBanned = true; - user1Before.isAdmin = true; - user1Before.updatedAtMs = 100; - - const user2Before = userManager.users.get("User1"); - user2Before.instanceStats.set(10, new PlayerStats({ join_count: 7 })); - user2Before.instanceStats.set(11, new PlayerStats({ join_count: 9 })); - user2Before.isWhitelisted = false; - user2Before.isBanned = false; - user2Before.isAdmin = false; - user1Before.updatedAtMs = 200; - - const user3Before = userManager.users.get("user3"); - user3Before.instanceStats.set(10, new PlayerStats({ join_count: 11 })); - user3Before.instanceStats.set(11, new PlayerStats({ join_count: 13 })); - user3Before.isWhitelisted = false; - user3Before.isAdmin = true; - - // Save and reload the user data - await fs.emptyDir("./temp/test/user_manager"); - await userManager.save("./temp/test/user_manager/load.json"); - names.map(name => userManager.users.delete(name)); - await userManager.load("./temp/test/user_manager/load.json"); - - // Check it was loaded correctly - const user1After = userManager.users.get("user1"); - const user2After = userManager.users.get("User1"); - const user3After = userManager.users.get("user3"); - assert.notEqual(user1After, undefined, "'user1' was not loaded"); - assert.equal(user2After, undefined, "'User1' was loaded"); - assert.notEqual(user3After, undefined, "'user3' was not loaded"); - - assert.equal(user1After.isAdmin, false, "User 1 is admin"); - assert.equal(user1After.isBanned, true, "User 1 is not banned"); - assert.equal(user1After.isWhitelisted, true, "User 1 is not whitelisted"); - assert.equal(user1After.banReason, "ban reason here", "User 1 ban reason is wrong"); - assert.equal(user1After.instanceStats.get(9)?.joinCount, 3, "User 1 instance 9 stats are wrong"); - assert.equal(user1After.instanceStats.get(10)?.joinCount, 12, "User 1 instance 10 stats are wrong"); - assert.equal(user1After.instanceStats.get(11)?.joinCount, 9, "User 1 instance 11 stats are wrong"); - - assert.equal(user3After.isAdmin, true, "User 3 is not admin"); - assert.equal(user3After.isWhitelisted, false, "User 3 is whitelisted"); - assert.equal(user3After.instanceStats.get(10)?.joinCount, 11, "User 3 instance 10 stats are wrong"); - assert.equal(user3After.instanceStats.get(11)?.joinCount, 13, "User 3 instance 11 stats are wrong"); - }); - }); - }); -}); diff --git a/test/controller/UserManager.test.js b/test/controller/UserManager.test.js new file mode 100644 index 00000000..c010b9fe --- /dev/null +++ b/test/controller/UserManager.test.js @@ -0,0 +1,172 @@ +"use strict"; +const path = require("path"); +const fs = require("fs-extra"); +const assert = require("assert").strict; +const jwt = require("jsonwebtoken"); +const { Role, SubscribableDatastore, ControllerConfig } = require("@clusterio/lib"); +const { UserRecord, UserManager } = require("@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.emptyDir(TEMP_DIR); + }); + + 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)); + }); + }); + + 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.writeJson(usersFile, { users: [] }); + await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile); + const content = await fs.readJson(usersFile); + assert.deepEqual(content, { users: [] }); + }); + + it("should throw if users key missing in legacy file", async function () { + await fs.writeJson(usersFile, { 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.writeJson(usersFile, legacy); + + await UserManager.attemptMigrateLegacyUsersFile(usersFile, rolesFile); + + const writtenRoles = await fs.readJson(rolesFile); + const writtenUsers = await fs.readJson(usersFile); + + assert.deepEqual(writtenRoles, [{ id: 1 }, { id: 2 }]); + assert.deepEqual(writtenUsers, [{ name: "Karl" }]); + }); + }); +}); diff --git a/test/controller/UserRecord.test.js b/test/controller/UserRecord.test.js new file mode 100644 index 00000000..fe7c6ccb --- /dev/null +++ b/test/controller/UserRecord.test.js @@ -0,0 +1,148 @@ +"use strict"; +const assert = require("assert").strict; +const { UserDetails, PlayerStats } = require("@clusterio/lib"); +const { UserRecord } = require("@clusterio/controller"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); + +describe("controller/UserRecord", function () { + it("should be round trip serialisable", function () { + testRoundTripJsonSerialisable(UserRecord, testMatrix( + [0, 50], // tokenValidAfter + ["TestUser"], // name + )); + }); + + describe("constructor", function () { + it("should default tokenValidAfter to 0", function () { + const user = new UserRecord(undefined, "TestUser"); + assert.equal(user.tokenValidAfter, 0); + }); + + it("should set tokenValidAfter when provided", function () { + const user = new UserRecord(123, "TestUser"); + assert.equal(user.tokenValidAfter, 123); + }); + }); + + describe("static fromJSON()", function () { + it("should construct from JSON without token_valid_after", function () { + const json = { + name: "TestUser", + }; + + const user = UserRecord.fromJSON(json); + assert.equal(user.tokenValidAfter, 0); + assert.equal(user.name, "TestUser"); + }); + + it("should construct from JSON with token_valid_after", function () { + const json = { + name: "TestUser", + token_valid_after: 99, + }; + + const user = UserRecord.fromJSON(json); + assert.equal(user.tokenValidAfter, 99); + }); + }); + + describe(".toJSON()", function () { + it("should omit token_valid_after when 0", function () { + const user = new UserRecord(0, "TestUser"); + + const json = user.toJSON(); + assert.equal(json.token_valid_after, undefined); + }); + + it("should include token_valid_after when > 0", function () { + const user = new UserRecord(42, "TestUser"); + + const json = user.toJSON(); + assert.equal(json.token_valid_after, 42); + }); + }); + + describe("static fromUserDetails()", function () { + it("should copy all fields from UserDetails", function () { + const details = new UserDetails( + "TestUser", new Set([1]), new Set([2]), + true, true, false, "reason", + 100, false, + new Map() + ); + + const record = UserRecord.fromUserDetails(details, 55); + + assert.equal(record.name, details.name); + assert.deepEqual(record.roleIds, details.roleIds); + assert.deepEqual(record.instances, details.instances); + assert.equal(record.isAdmin, details.isAdmin); + assert.equal(record.tokenValidAfter, 55); + }); + }); + + describe(".toUserDetails()", function () { + it("should create equivalent UserDetails without tokenValidAfter", function () { + const record = new UserRecord(10, "TestUser"); + const details = record.toUserDetails(); + + assert(details instanceof UserDetails); + assert.equal(details.name, record.name); + assert.equal(details.tokenValidAfter, undefined); + }); + }); + + describe("merge", function () { + it("should throw if ids do not match", function () { + const a = new UserRecord(0, "UserA"); + const b = new UserDetails("UserB"); + + assert.throws(() => a.merge(b), /different ids/); + }); + + it("should merge sets and boolean flags", function () { + const a = new UserRecord( + 0, "TestUser", new Set([1]), new Set([10]), + true, false, false, "old", + 100, false + ); + + const b = new UserDetails( + "TestUser", new Set([2]), new Set([20]), + false, true, true, "new", + 200, false + ); + + const beforeUpdated = a.updatedAtMs; + + a.merge(b); + + assert.deepEqual(a.roleIds, new Set([1, 2])); // roleIds union + assert.deepEqual(a.instances, new Set([10, 20])); // instances union + assert.equal(a.isAdmin, false); // isAdmin uses && + assert.equal(a.isBanned, true); // isBanned uses || + assert.equal(a.isWhitelisted, true); // isWhitelisted uses || + assert.equal(a.banReason, "new"); // banReason chosen by updatedAtMs comparison + assert(a.updatedAtMs > beforeUpdated); // updatedAtMs changed + }); + + it("should merge instanceStats entries", function () { + const a = UserRecord.fromJSON({ name: "TestUser", instance_stats: [ + [1, { join_count: 1 }], + ]}); + + const b = UserRecord.fromJSON({ name: "TestUser", instance_stats: [ + [1, { join_count: 3 }], // merge existing + [2, { join_count: 5 }], // add new + ]}); + + a.merge(b); + + assert.equal(a.instanceStats.has(1), true); + assert.equal(a.instanceStats.get(1).joinCount, 4); + assert.equal(a.instanceStats.has(2), true); + assert.equal(a.instanceStats.get(2).joinCount, 5); + }); + }); +}); diff --git a/test/controller/routes.js b/test/controller/routes.js index ddccd692..9cbbf4f8 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -2,7 +2,6 @@ const assert = require("assert").strict; const events = require("events"); const FormData = require("form-data"); -const jwt = require("jsonwebtoken"); const http = require("http"); const { wait } = require("@clusterio/lib"); @@ -88,9 +87,10 @@ describe("controller/src/routes", function() { }); }); describe("/api/upload-save", function() { - let endpoint; + let endpoint, testToken; beforeEach(function() { endpoint = `http://localhost:${port}/api/upload-save`; + testToken = controller.users.signUserToken({ id: "test" }); }); it("should respond with 401 with invalid token", async function() { let response; @@ -113,21 +113,19 @@ describe("controller/src/routes", function() { method: "POST", headers: { "Content-Type": "application/zip", - "X-Access-Token": jwt.sign( - { aud: "user", user: "invalid" }, Buffer.from("TestSecretDoNotUse", "base64") - ), + "X-Access-Token": controller.users.signUserToken({ id: "invalid" }), }, body: "totally a zip file", }); assert.equal(response.status, 401); - controller.userManager.getByName("test").tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000); + const user = controller.users.getOrCreateUser("test"); + user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000); + user.saveRecord(); response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, { method: "POST", headers: { "Content-Type": "application/zip", - "X-Access-Token": jwt.sign( - { aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64") - ), + "X-Access-Token": testToken, }, body: "totally a zip file", }); @@ -135,20 +133,18 @@ describe("controller/src/routes", function() { }); it("should respond with 403 when user has insufficient permission", async function() { + const user = controller.users.getOrCreateUser("player"); let response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, { method: "POST", headers: { "Content-Type": "application/zip", - "X-Access-Token": jwt.sign( - { aud: "user", user: "player" }, Buffer.from("TestSecretDoNotUse", "base64") - ), + "X-Access-Token": controller.users.signUserToken(user), }, body: "totally a zip file", }); assert.equal(response.status, 403); }); - let testToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); it("should respond with 415 if content type is missing or invalid", async function() { let response; response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, { diff --git a/test/host.js b/test/host.js index fe50b04d..63aa61bc 100644 --- a/test/host.js +++ b/test/host.js @@ -12,7 +12,7 @@ describe("Host testing", function() { describe("discoverInstances()", function() { it("should discover test instance", async function() { const instancePath = path.join("test", "file", "instances"); - const instanceInfos = await _discoverInstances(instancePath); + const instances = await _discoverInstances(instancePath); const configPath = path.join(instancePath, "test", "instance.json"); const referenceConfig = new lib.InstanceConfig("host", { @@ -21,7 +21,7 @@ describe("Host testing", function() { "instance.name": "test", }, configPath); - assert.deepEqual(instanceInfos, new Map([ + assert.deepEqual(instances, new Map([ [1, { config: referenceConfig, path: path.join(instancePath, "test"), diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 3fe1805f..d874b7d3 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -109,10 +109,12 @@ describe("Integration of Clusterio", function() { describe("clusteriocontroller", function() { describe("bootstrap generate-user-token", function() { it("work for existing user", async function() { + slowTest(this); await execController("bootstrap generate-user-token test"); }); it("fails if user does not exist", async function() { + slowTest(this); await assert.rejects( execController("bootstrap generate-user-token invalid") ); @@ -935,20 +937,22 @@ describe("Integration of Clusterio", function() { }); it("should create the user if instructed to", async function() { slowTest(this); - getControl().userUpdates = []; + const userUpdates = []; + getControl().userUpdates = userUpdates; for (let [listName, prop] of lists) { await execCtl(`user set-${listName} --create test_create_${listName}`); let user = (await getUsers()).get(`test_create_${listName}`); assert.equal(user && user[prop], true, `user not created and added to ${listName}`); } - assert.equal(getControl().userUpdates.length, 3); + assert.equal(userUpdates.length, 6); // 1 for create, 1 for setting the property }); it("should send ban reason", async function() { slowTest(this); - getControl().userUpdates = []; + const userUpdates = []; + getControl().userUpdates = userUpdates; await execCtl("user set-banned --create test_ban_reason --reason a-reason"); - assert.equal(getControl().userUpdates.length, 1); - assert.equal(getControl().userUpdates[0].banReason, "a-reason"); + assert.equal(userUpdates.length, 2); // 1 for create, 1 for ban reason + assert.equal(userUpdates[1].banReason, "a-reason"); let user = await getUser("test_ban_reason"); assert.equal(user.banReason, "a-reason"); }); @@ -1719,6 +1723,12 @@ describe("Integration of Clusterio", function() { }); describe("user import", function() { + beforeEach(async function() { + slowTest(this); + await execCtl("user set-admin import_control --revoke --create"); + await execCtl("user set-banned import_control --pardon"); + await execCtl("user set-whitelisted import_control --remove"); + }); it("should import users", async function() { await fs.writeJSON(path.join("temp", "test", "import-users.json"), { export_version: "2.0.0.alpha20", @@ -1728,12 +1738,14 @@ describe("Integration of Clusterio", function() { { username: "import_user_ban_reason", is_banned: true, ban_reason: "banned" }, { username: "import_user_whitelist", is_whitelisted: true }, { username: "import_user_none" }, + { username: "import_control", is_admin: true }, ], }); await execCtl("user import --users import-users.json"); const users = await getUsers(); + assert(users.get("import_control").isAdmin, "import_control was not admin"); assert(users.get("import_user_admin").isAdmin, "import_user_admin was not admin"); assert(users.get("import_user_banned").isBanned, "import_user_banned was not banned"); assert( @@ -1765,6 +1777,7 @@ describe("Integration of Clusterio", function() { await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [ { username: "import_ban_reason", reason: "banned" }, "import_ban", + "import_control", ]); await execCtl("user import --bans import-bans.json"); @@ -1778,6 +1791,8 @@ describe("Integration of Clusterio", function() { const userBar = users.get("import_ban"); assert(userBar.isBanned, "import_ban was not banned"); assert.equal(userBar.banReason, "", "import_ban had incorrect ban reason"); + + assert(users.get("import_control").isBanned, "import_control was not banned"); }); it("should import bans by filename", async function() { await fs.writeJSON(path.join("temp", "test", "import-bans.json"), [ @@ -1789,11 +1804,13 @@ describe("Integration of Clusterio", function() { }); it("should import admins", async function() { await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [ - "import_admin", + "import_admin", "import_control", ]); await execCtl("user import --admins import-admins.json"); - assert((await getUsers()).get("import_admin").isAdmin, "import_admin was not admin"); + const users = await getUsers(); + assert(users.get("import_admin").isAdmin, "import_admin was not admin"); + assert(users.get("import_control").isAdmin, "import_control was not admin"); }); it("should import admins by filename", async function() { await fs.writeJSON(path.join("temp", "test", "import-admins.json"), [ @@ -1805,14 +1822,13 @@ describe("Integration of Clusterio", function() { }); it("should import whitelist", async function() { await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [ - "import_whitelist", + "import_whitelist", "import_control", ]); await execCtl("user import --whitelist import-whitelist.json"); - assert( - (await getUsers()).get("import_whitelist").isWhitelisted, - "import_whitelist was not whitelisted" - ); + const users = await getUsers(); + assert(users.get("import_whitelist").isWhitelisted, "import_whitelist was not whitelisted"); + assert(users.get("import_control").isWhitelisted, "import_control was not whitelisted"); }); it("should import whitelist by filename", async function() { await fs.writeJSON(path.join("temp", "test", "import-whitelist.json"), [ @@ -1882,7 +1898,10 @@ describe("Integration of Clusterio", function() { it("should restore users", async function() { await fs.writeJSON(path.join("temp", "test", "restore-users.json"), { export_version: "2.0.0.alpha20", - users: [{ username: "restore_user", is_admin: true, is_whitelisted: true }], + users: [ + { username: "restore_user", is_admin: true, is_whitelisted: true }, + { username: "restore_user_two", is_banned: true, ban_reason: "Test" }, + ], }); await execCtl("user restore restore-users.json"); @@ -1892,10 +1911,14 @@ describe("Integration of Clusterio", function() { assert(user.isAdmin, "restore_user was not admin"); assert(user.isWhitelisted, "restore_user was not whitelisted"); + const user2 = users.get("restore_user_two"); + assert(user2.isBanned, "restore_user_two was not banned"); + assert.equal(user2.banReason, "Test"); + const control = users.get("restore_control"); - assert(!control.isAdmin, "restore_user was admin"); - assert(!control.isBanned, "restore_user was banned"); - assert(!control.isWhitelisted, "restore_user was whitelisted"); + assert(!control.isAdmin, "restore_control was admin"); + assert(!control.isBanned, "restore_control was banned"); + assert(!control.isWhitelisted, "restore_control was whitelisted"); const data = await fs.readJson(path.join("temp", "test", "users-backup.json")); assert(data.users, "Users array does not exist"); @@ -1921,7 +1944,7 @@ describe("Integration of Clusterio", function() { assert(userBar.isBanned, "restore_ban was not banned"); assert.equal(userBar.banReason, "", "restore_ban had incorrect ban reason"); - assert(!users.get("restore_control").isBanned, "restore_user was banned"); + assert(!users.get("restore_control").isBanned, "restore_control was banned"); const data = await fs.readJson(path.join("temp", "test", "bans-backup.json")); assert(data.indexOf("restore_control") >= 0, "restore_control is missing"); @@ -1935,7 +1958,7 @@ describe("Integration of Clusterio", function() { const users = await getUsers(); assert(users.get("restore_admin").isAdmin, "restore_admin was not admin"); - assert(!users.get("restore_control").isAdmin, "restore_user was admin"); + assert(!users.get("restore_control").isAdmin, "restore_control was admin"); const data = await fs.readJson(path.join("temp", "test", "admins-backup.json")); assert(data.indexOf("restore_control") >= 0, "restore_control is missing"); @@ -1949,7 +1972,7 @@ describe("Integration of Clusterio", function() { const users = await getUsers(); assert(users.get("restore_whitelist").isWhitelisted, "restore_whitelist was not whitelisted"); - assert(!users.get("restore_control").isWhitelisted, "restore_user was whitelisted"); + assert(!users.get("restore_control").isWhitelisted, "restore_control was whitelisted"); const data = await fs.readJson(path.join("temp", "test", "whitelist-backup.json")); assert(data.indexOf("restore_control") >= 0, "restore_control is missing"); diff --git a/test/integration/routing.js b/test/integration/routing.js index 827e6103..02c5fcff 100644 --- a/test/integration/routing.js +++ b/test/integration/routing.js @@ -3,7 +3,7 @@ const assert = require("assert").strict; const lib = require("@clusterio/lib"); const { Host, Instance, InstanceConnection } = require("@clusterio/host"); -const { ControlConnection, Controller, HostConnection, InstanceInfo } = require("@clusterio/controller"); +const { ControlConnection, Controller, HostConnection, InstanceRecord } = require("@clusterio/controller"); // Although there are no imports, the file has side-effects such as "before" which can effect tests require("./index"); @@ -38,8 +38,8 @@ function connectInstance(controller, host, instanceId) { const instanceConfig = new lib.InstanceConfig("host"); instanceConfig.set("instance.id", instanceId); instanceConfig.set("instance.assigned_host", host.config.get("host.id")); - host.instanceInfos.set(instanceId, { path: "invalid", config: instanceConfig }); - controller.instances.set(new InstanceInfo(instanceConfig, "stopped")); + host.assignedInstances.set(instanceId, { path: "invalid", config: instanceConfig }); + controller.instances.records.set(new InstanceRecord(instanceConfig, "stopped")); const instance = new Instance(host, instanceSide, "invalid", "invalid", instanceConfig); const instanceConnection = new InstanceConnection(hostSide, host, instance); host.instanceConnections.set(instanceId, instanceConnection); @@ -53,10 +53,7 @@ function connectInstance(controller, host, instanceId) { function connectControl(controller, controlId) { const [controllerSide, controlSide] = lib.VirtualConnector.makePair(addr("controller"), addr({ controlId })); const registerData = new lib.RegisterControlData("", "0.0.0"); - let user = controller.userManager.getByName("test"); - if (!user) { - user = controller.userManager.createUser("test"); - } + const user = controller.users.getOrCreateUser("test"); const controlConnection = new ControlConnection(registerData, controllerSide, controller, user, controlId); controller.wsServer.controlConnections.set(controlId, controlConnection); return new Control(controlSide); diff --git a/test/integration/server.js b/test/integration/server.js index aa5eff48..21948fcf 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -5,6 +5,7 @@ const fs = require("fs-extra"); const path = require("path"); const lib = require("@clusterio/lib"); +const { InstanceManager } = require("@clusterio/controller"); const { FactorioServer, _getFactorioVersion } = require("@clusterio/host/dist/node/src/server"); const { logger } = lib; @@ -78,20 +79,16 @@ describe("Integration of host/src/server", function() { assert(typeof settings === "object"); }); - it("contains the settings used by Clusterio", async function() { - let keysUsed = new Set([ - "name", "description", "tags", "visibility", "username", "token", - "game_password", "require_user_verification", "allow_commands", - "auto_pause", - ]); + it("aligns with the default settings in Clusterio", async function() { + const unusedDefaultKeys = new Set(Object.keys(InstanceManager.DefaultFactorioSettings)); - for (let key of Object.keys(settings)) { - keysUsed.delete(key); + for (const key of Object.keys(settings)) { + unusedDefaultKeys.delete(key); } assert( - keysUsed.size === 0, - `Factorio's server-settings.example.json does not contain the key(s) ${[...keysUsed]}` + unusedDefaultKeys.size === 0, + `Factorio's server-settings.example.json does not contain the key(s) ${[...unusedDefaultKeys]}` ); }); }); diff --git a/test/lib/data/HostDetails.test.js b/test/lib/data/HostDetails.test.js new file mode 100644 index 00000000..5c9405b3 --- /dev/null +++ b/test/lib/data/HostDetails.test.js @@ -0,0 +1,145 @@ +"use strict"; +const assert = require("assert").strict; +const { HostDetails } = require("@clusterio/lib"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); + +describe("lib/data/HostDetails", function () { + + it("should be round trip serialisable", function () { + testRoundTripJsonSerialisable(HostDetails, testMatrix( + ["1.0.0"], // version (required) + ["TestHost"], // name (required) + [1], // id (required) + [true, false], // connected (required) + [undefined, "10.0.0.1"], // remoteAddress + [undefined, "203.0.113.1"], // publicAddress + [undefined, 123456], // tokenValidAfter (sec) + [undefined, 999999], // updatedAtMs (ms) + [undefined, true], // isDeleted + )); + }); + + describe("constructor", function () { + it("should set defaults correctly", function () { + const host = new HostDetails("1.0.0", "TestHost", 1, true); + + assert.equal(host.version, "1.0.0"); + assert.equal(host.name, "TestHost"); + assert.equal(host.id, 1); + assert.equal(host.connected, true); + assert.equal(host.remoteAddress, ""); + assert.equal(host.publicAddress, ""); + assert.equal(host.tokenValidAfter, 0); + assert.equal(host.updatedAtMs, 0); + assert.equal(host.isDeleted, false); + }); + + it("should accept all constructor parameters", function () { + const host = new HostDetails( + "2.0.0", "FullHost", 42, + false, "10.1.1.1", "198.51.100.5", + 123, 999, true + ); + + assert.equal(host.version, "2.0.0"); + assert.equal(host.name, "FullHost"); + assert.equal(host.id, 42); + assert.equal(host.connected, false); + assert.equal(host.remoteAddress, "10.1.1.1"); + assert.equal(host.publicAddress, "198.51.100.5"); + assert.equal(host.tokenValidAfter, 123); + assert.equal(host.updatedAtMs, 999); + assert.equal(host.isDeleted, true); + }); + }); + + describe(".toJSON()", function () { + it("should only include required fields when optional fields are not given", function () { + const host = new HostDetails("1.0.0", "TestHost", 1, false); + + assert.deepEqual(host.toJSON(), { + version: "1.0.0", + name: "TestHost", + id: 1, + connected: false, + }); + }); + + it("should include all optional fields when set", function () { + const host = new HostDetails( + "2.0.0", "FullHost", 99, + true, "10.0.0.5", "203.0.113.5", + 500, 1000, true + ); + + assert.deepEqual(host.toJSON(), { + version: "2.0.0", + name: "FullHost", + id: 99, + connected: true, + remoteAddress: "10.0.0.5", + publicAddress: "203.0.113.5", + tokenValidAfter: 500, + updatedAtMs: 1000, + isDeleted: true, + }); + }); + + it("should omit optional fields when equal to defaults", function () { + const host = new HostDetails( + "1.0.0", "TestHost", 1, + true, "", "", + 0, 0, false + ); + + assert.deepEqual(host.toJSON(), { + version: "1.0.0", + name: "TestHost", + id: 1, + connected: true, + }); + }); + }); + + describe("static fromJSON()", function () { + it("should construct minimal object", function () { + const host = HostDetails.fromJSON({ + version: "1.0.0", + name: "TestHost", + id: 1, + connected: true, + }); + + assert.equal(host.version, "1.0.0"); + assert.equal(host.name, "TestHost"); + assert.equal(host.id, 1); + assert.equal(host.connected, true); + assert.equal(host.remoteAddress, ""); + assert.equal(host.publicAddress, ""); + assert.equal(host.tokenValidAfter, 0); + assert.equal(host.updatedAtMs, 0); + assert.equal(host.isDeleted, false); + }); + + it("should preserve optional fields exactly when provided", function () { + const host = HostDetails.fromJSON({ + version: "3.0.0", + name: "RemoteHost", + id: 7, + connected: false, + remoteAddress: "192.168.1.5", + publicAddress: "198.51.100.10", + tokenValidAfter: 100, + updatedAtMs: 2000, + isDeleted: true, + }); + + assert.equal(host.remoteAddress, "192.168.1.5"); + assert.equal(host.publicAddress, "198.51.100.10"); + assert.equal(host.tokenValidAfter, 100); + assert.equal(host.updatedAtMs, 2000); + assert.equal(host.isDeleted, true); + }); + }); +}); diff --git a/test/lib/data/InstanceDetails.test.js b/test/lib/data/InstanceDetails.test.js new file mode 100644 index 00000000..cb949c18 --- /dev/null +++ b/test/lib/data/InstanceDetails.test.js @@ -0,0 +1,153 @@ +"use strict"; +const assert = require("assert").strict; +const { InstanceDetails } = require("@clusterio/lib"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); + +describe("lib/data/InstanceDetails", function () { + + it("should be round trip serialisable", function () { + testRoundTripJsonSerialisable(InstanceDetails, testMatrix( + ["TestInstance"], // name (required) + [1], // id (required) + [undefined, 5], // assignedHost + [undefined, 34197], // gamePort + ["running", "stopped"], // status (required) + [undefined, "1.0.0"], // factorioVersion + [undefined, 1234], // startedAtMs + [undefined, 9999], // updatedAtMs + [undefined, true], // excludeFromStartAll + )); + }); + + describe("constructor", function () { + it("should set defaults correctly", function () { + const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running"); + + assert.equal(instance.name, "TestInstance"); + assert.equal(instance.id, 1); + assert.equal(instance.assignedHost, undefined); + assert.equal(instance.gamePort, undefined); + assert.equal(instance.status, "running"); + assert.equal(instance.factorioVersion, undefined); + assert.equal(instance.startedAtMs, 0); + assert.equal(instance.updatedAtMs, 0); + assert.equal(instance.excludeFromStartAll, false); + }); + + it("should accept all constructor parameters", function () { + const instance = new InstanceDetails( + "FullInstance", 42, 10, 34197, + "starting", "1.0.0", 1000, 2000, true + ); + + assert.equal(instance.name, "FullInstance"); + assert.equal(instance.id, 42); + assert.equal(instance.assignedHost, 10); + assert.equal(instance.gamePort, 34197); + assert.equal(instance.status, "starting"); + assert.equal(instance.factorioVersion, "1.0.0"); + assert.equal(instance.startedAtMs, 1000); + assert.equal(instance.updatedAtMs, 2000); + assert.equal(instance.excludeFromStartAll, true); + }); + }); + + describe(".toJSON()", function () { + it("should only include required fields when optional fields are not given", function () { + const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running"); + + assert.deepEqual(instance.toJSON(), { + name: "TestInstance", + id: 1, + status: "running", + }); + }); + + it("should include all optional fields when set", function () { + const instance = new InstanceDetails( + "FullInstance", 99, 5, 34197, + "running", "1.0.0", 500, 1000, true + ); + + assert.deepEqual(instance.toJSON(), { + name: "FullInstance", + id: 99, + status: "running", + assignedHost: 5, + gamePort: 34197, + factorioVersion: "1.0.0", + startedAtMs: 500, + updatedAtMs: 1000, + excludeFromStartAll: true, + }); + }); + + it("should omit optional fields when equal to defaults", function () { + const instance = new InstanceDetails( + "TestInstance", 1, undefined, undefined, + "stopped", undefined, 0, 0, false + ); + + assert.deepEqual(instance.toJSON(), { + name: "TestInstance", + id: 1, + status: "stopped", + }); + }); + }); + + describe("static fromJSON()", function () { + it("should construct minimal object and apply constructor defaults", function () { + const instance = InstanceDetails.fromJSON({ + name: "TestInstance", + id: 1, + status: "running", + }); + + assert.equal(instance.name, "TestInstance"); + assert.equal(instance.id, 1); + assert.equal(instance.assignedHost, undefined); + assert.equal(instance.gamePort, undefined); + assert.equal(instance.factorioVersion, undefined); + assert.equal(instance.startedAtMs, 0); + assert.equal(instance.updatedAtMs, 0); + assert.equal(instance.excludeFromStartAll, false); + }); + + it("should preserve optional fields exactly when provided", function () { + const instance = InstanceDetails.fromJSON({ + name: "RemoteInstance", + id: 7, + status: "starting", + assignedHost: 2, + gamePort: 34197, + factorioVersion: "1.0.0", + startedAtMs: 100, + updatedAtMs: 200, + excludeFromStartAll: true, + }); + + assert.equal(instance.assignedHost, 2); + assert.equal(instance.gamePort, 34197); + assert.equal(instance.factorioVersion, "1.0.0"); + assert.equal(instance.startedAtMs, 100); + assert.equal(instance.updatedAtMs, 200); + assert.equal(instance.excludeFromStartAll, true); + }); + }); + + describe("get .isDeleted", function () { + it("should return true when status is 'deleted'", function () { + const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "deleted"); + + assert.equal(instance.isDeleted, true); + }); + + it("should return false when status is not 'deleted'", function () { + const instance = new InstanceDetails("TestInstance", 1, undefined, undefined, "running"); + + assert.equal(instance.isDeleted, false); + }); + }); +}); diff --git a/test/lib/data/User.js b/test/lib/data/User.js deleted file mode 100644 index 5ae52b7d..00000000 --- a/test/lib/data/User.js +++ /dev/null @@ -1,71 +0,0 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); - - -describe("lib/data/User", function() { - describe("class User", function() { - it("should round trip serialize", function() { - function test_roundtrip(serialized) { - let user = lib.User.fromJSON(serialized); - let user_serialized = user.toJSON(); - assert.deepEqual(user_serialized, serialized); - let user_deserialized = lib.User.fromJSON(user_serialized); - assert.deepEqual(user_deserialized, user); - } - - test_roundtrip({ name: "admin", roles: [1] }); - test_roundtrip({ name: "user", is_admin: true, is_whitelisted: true }); - test_roundtrip({ name: "user", is_banned: true, ban_reason: "Bad user" }); - test_roundtrip({ name: "user", instance_stats: [[1, { join_count: 1 }]]}); - }); - it("should keep invalid roles", function() { - let user = lib.User.fromJSON({ name: "test", roles: [1, 4, 55] }); - assert.equal(user.roleIds.size, 3, "Unexpected count of roles"); - }); - it("should calculate playerStats", function() { - let user = lib.User.fromJSON({ name: "test", roles: [1], instance_stats: [ - [1, { - join_count: 1, - online_time_ms: 60e3, - last_join_at_ms: new Date("2020-05T12:00Z").getTime(), - last_leave_at_ms: new Date("2020-05T12:01Z").getTime(), - last_leave_reason: "quit", - }], - [2, { - join_count: 1, - online_time_ms: 0, - first_join_at_ms: new Date("2020-05T12:00Z").getTime(), - last_join_at_ms: new Date("2020-05T12:02Z").getTime(), - }], - ]}); - - assert.equal(user.playerStats.onlineTimeMs, 60e3); - assert.equal(user.playerStats.joinCount, 2); - assert.deepEqual(user.playerStats.firstJoinAt, new Date("2020-05T12:00Z")); - assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T12:02Z")); - assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T12:01Z")); - assert.equal(user.playerStats.lastLeaveReason, "quit"); - - user.instanceStats.set( - 3, - lib.PlayerStats.fromJSON({ - join_count: 2, - online_time_ms: 120e3, - first_join_at_ms: new Date("2020-05T11:00Z").getTime(), - last_join_at_ms: new Date("2020-05T13:00Z").getTime(), - last_leave_at_ms: new Date("2020-05T13:02Z").getTime(), - last_leave_reason: "afk", - }), - ); - - user.recalculatePlayerStats(); - assert.equal(user.playerStats.onlineTimeMs, 180e3); - assert.equal(user.playerStats.joinCount, 4); - assert.deepEqual(user.playerStats.firstJoinAt, new Date("2020-05T11:00Z")); - assert.deepEqual(user.playerStats.lastJoinAt, new Date("2020-05T13:00Z")); - assert.deepEqual(user.playerStats.lastLeaveAt, new Date("2020-05T13:02Z")); - assert.equal(user.playerStats.lastLeaveReason, "afk"); - }); - }); -}); diff --git a/test/lib/data/UserDetails.test.js b/test/lib/data/UserDetails.test.js new file mode 100644 index 00000000..e155c4df --- /dev/null +++ b/test/lib/data/UserDetails.test.js @@ -0,0 +1,267 @@ +"use strict"; +const assert = require("assert").strict; +const { UserDetails, PlayerStats } = require("@clusterio/lib"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); + +describe("lib/data/UserDetails", function () { + it("should be round trip serialisable", function () { + testRoundTripJsonSerialisable(UserDetails, testMatrix( + ["TestUser"], // name (required) + [undefined, new Set([1])], // roleIds + [undefined, new Set([2])], // instances + [undefined, true], // isAdmin + [undefined, true], // isBanned + [undefined, true], // isWhitelisted + [undefined, "reason"], // banReason + [undefined, 123], // updatedAtMs + [undefined, true], // isDeleted + [ // instanceStats + undefined, + new Map([ + [1, PlayerStats.fromJSON({ join_count: 1, online_time_ms: 100 })], + ]), + ], + )); + }); + + describe("constructor", function () { + it("should set defaults correctly", function () { + const user = new UserDetails("TestUser"); + + assert.equal(user.name, "TestUser"); + assert.equal(user.roleIds.size, 0); + assert.equal(user.instances.size, 0); + assert.equal(user.isAdmin, false); + assert.equal(user.isBanned, false); + assert.equal(user.isWhitelisted, false); + assert.equal(user.banReason, ""); + assert.equal(user.updatedAtMs, 0); + assert.equal(user.isDeleted, false); + assert(user.playerStats instanceof PlayerStats); + assert(user.instanceStats instanceof Map); + }); + + it("should accept all constructor parameters", function () { + const user = new UserDetails( + "TestUser", new Set([1]), new Set([2]), + true, true, true, "reason", + 100, true, + new Map() + ); + + assert.equal(user.name, "TestUser"); + assert.deepEqual(user.roleIds, new Set([1])); + assert.deepEqual(user.instances, new Set([2])); + assert.equal(user.isAdmin, true); + assert.equal(user.isBanned, true); + assert.equal(user.isWhitelisted, true); + assert.equal(user.banReason, "reason"); + assert.equal(user.updatedAtMs, 100); + assert.equal(user.isDeleted, true); + }); + }); + + describe("get .id", function () { + it("should return lowercase name", function () { + const user = new UserDetails("AdMiN"); + assert.equal(user.id, "admin"); + }); + + it("should reflect name changes dynamically", function () { + const user = new UserDetails("User"); + user.name = "NEWNAME"; + assert.equal(user.id, "newname"); + }); + }); + + describe(".toJSON()", function () { + it("should only include required fields when optional fields are not given", function () { + const user = new UserDetails("Test"); + assert.deepEqual(user.toJSON(), { name: "Test" }); + }); + + it("should include all optional fields when set", function () { + const stats = new PlayerStats({ join_count: 1 }); + const user = new UserDetails( + "Test", new Set([1, 2]), new Set([10]), + true, true, true, "reason", + 123, true, + new Map([[1, stats]]) + ); + + assert.deepEqual(user.toJSON(), { + name: "Test", + roles: [1, 2], + instances: [10], + is_admin: true, + is_banned: true, + is_whitelisted: true, + ban_reason: "reason", + updated_at_ms: 123, + is_deleted: true, + instance_stats: [[1, stats.toJSON()]], + }); + }); + + it("should omit optional fields when equal to defaults", function () { + const user = new UserDetails( + "Test", new Set(), new Set(), + false, false, false, "", + 0, false, + new Map() + ); + + assert.deepEqual(user.toJSON(), { name: "Test" }); + }); + }); + + describe("static fromJSON()", function () { + it("should construct minimal object", function () { + const user = UserDetails.fromJSON({ name: "Test" }); + + assert.equal(user.name, "Test"); + assert.deepEqual(user.roleIds, new Set()); + assert.deepEqual(user.instances, new Set()); + assert.deepEqual(user.isAdmin, false); + assert.equal(user.isBanned, false); + assert.equal(user.isWhitelisted, false); + assert.equal(user.banReason, ""); + assert.equal(user.updatedAtMs, 0); + assert.equal(user.isDeleted, false); + assert.deepEqual(user.instanceStats, new Map()); + }); + + it("should preserve optional fields exactly when provided", function () { + const stats = new PlayerStats({ join_count: 1 }); + const user = UserDetails.fromJSON({ + name: "Test", + roles: [1, 2], + instances: [10], + is_admin: true, + is_banned: true, + is_whitelisted: true, + ban_reason: "reason", + updated_at_ms: 123, + is_deleted: true, + instance_stats: [[1, stats.toJSON()]], + }); + + assert.equal(user.name, "Test"); + assert.deepEqual(user.roleIds, new Set([1, 2])); + assert.deepEqual(user.instances, new Set([10])); + assert.deepEqual(user.isAdmin, true); + assert.equal(user.isBanned, true); + assert.equal(user.isWhitelisted, true); + assert.equal(user.banReason, "reason"); + assert.equal(user.updatedAtMs, 123); + assert.equal(user.isDeleted, true); + assert.deepEqual(user.instanceStats, new Map([[1, stats]])); + }); + + it("should preserve roles exactly (including invalid ones)", function () { + const user = UserDetails.fromJSON({ + name: "Test", + roles: [1, 4, 55], + }); + + assert.equal(user.roleIds.size, 3); + assert(user.roleIds.has(55)); + }); + + it("should reconstruct instanceStats and calculate playerStats", function () { + const json = { + name: "Test", + instance_stats: [ + [1, { join_count: 1, online_time_ms: 100 }], + [2, { join_count: 2, online_time_ms: 200 }], + ], + }; + + const user = UserDetails.fromJSON(json); + + assert.equal(user.instanceStats.size, 2); + assert.equal(user.playerStats.joinCount, 3); + assert.equal(user.playerStats.onlineTimeMs, 300); + }); + }); + + describe(".recalculatePlayerStats()", function () { + it("should aggregate stats correctly across multiple instances", function () { + const user = UserDetails.fromJSON({ + name: "test", + instance_stats: [ + [1, { + join_count: 1, + online_time_ms: 60e3, + last_join_at_ms: new Date("2020-05-05T12:00Z").getTime(), + last_leave_at_ms: new Date("2020-05-05T12:01Z").getTime(), + last_leave_reason: "quit", + }], + [2, { + join_count: 1, + online_time_ms: 0, + first_join_at_ms: new Date("2020-05-05T12:00Z").getTime(), + last_join_at_ms: new Date("2020-05-05T12:02Z").getTime(), + }], + ], + }); + + assert.equal(user.playerStats.joinCount, 2); + assert.equal(user.playerStats.onlineTimeMs, 60e3); + + user.instanceStats.set(3, PlayerStats.fromJSON({ + join_count: 2, + online_time_ms: 120e3, + first_join_at_ms: new Date("2020-05-05T11:00Z").getTime(), + last_join_at_ms: new Date("2020-05-05T13:00Z").getTime(), + last_leave_at_ms: new Date("2020-05-05T13:02Z").getTime(), + last_leave_reason: "afk", + })); + + user.recalculatePlayerStats(); + + assert.equal(user.playerStats.joinCount, 4); + assert.equal(user.playerStats.onlineTimeMs, 180e3); + }); + }); + + describe("static _calculatePlayerStats()", function () { + it("should return empty stats for empty map", function () { + const stats = UserDetails._calculatePlayerStats(new Map()); + assert.equal(stats.joinCount, 0); + assert.equal(stats.onlineTimeMs, 0); + }); + + it("should pick earliest firstJoin and latest joins/leaves", function () { + const a = PlayerStats.fromJSON({ + first_join_at_ms: new Date("2020-01-01").getTime(), + last_join_at_ms: new Date("2020-01-02").getTime(), + last_leave_at_ms: new Date("2020-01-02").getTime(), + last_leave_reason: "quit", + join_count: 1, + online_time_ms: 100, + }); + + const b = PlayerStats.fromJSON({ + first_join_at_ms: new Date("2019-01-01").getTime(), + last_join_at_ms: new Date("2021-01-01").getTime(), + last_leave_at_ms: new Date("2021-01-02").getTime(), + last_leave_reason: "afk", + join_count: 2, + online_time_ms: 200, + }); + + const stats = UserDetails._calculatePlayerStats( + new Map([[1, a], [2, b]]) + ); + + assert.equal(stats.joinCount, 3); + assert.equal(stats.onlineTimeMs, 300); + assert.deepEqual(stats.firstJoinAt, new Date("2019-01-01")); + assert.deepEqual(stats.lastJoinAt, new Date("2021-01-01")); + assert.deepEqual(stats.lastLeaveAt, new Date("2021-01-02")); + assert.equal(stats.lastLeaveReason, "afk"); + }); + }); +}); diff --git a/test/lib/datastore.js b/test/lib/datastore.js index d4071c08..93b53277 100644 --- a/test/lib/datastore.js +++ b/test/lib/datastore.js @@ -180,6 +180,21 @@ describe("lib/datastore", function() { await fs.copy(path.join(testFiles, "json", "invalid.json"), dataPath); await assert.rejects(() => datastoreProvider.load(dataPath)); }); + it("writes a backup if size changes after load", async function() { + datastoreProvider = new lib.JsonDatastoreProvider( + dataPath, + json => json, // Nop + rawJson => rawJson, // Nop + () => undefined, // Throw away all data + ); + + const entriesBefore = await fs.readdir(baseDir); + const loadedData = await datastoreProvider.load(); + const entriesAfter = await fs.readdir(baseDir); + + assert.equal(entriesAfter.length, entriesBefore.length + 1, "Backup was not created"); + assert.equal(loadedData.size, 0); + }); }); }); @@ -260,6 +275,21 @@ describe("lib/datastore", function() { await fs.copy(path.join(testFiles, "json", "invalid.json"), dataPath); await assert.rejects(() => datastoreProvider.load(dataPath)); }); + it("writes a backup if size changes after load", async function() { + datastoreProvider = new lib.JsonIdDatastoreProvider( + dataPath, + json => json, // Nop + rawJson => rawJson, // Nop + () => undefined, // Throw away all data + ); + + const entriesBefore = await fs.readdir(baseDir); + const loadedData = await datastoreProvider.load(); + const entriesAfter = await fs.readdir(baseDir); + + assert.equal(entriesAfter.length, entriesBefore.length + 1, "Backup was not created"); + assert.equal(loadedData.size, 0); + }); }); }); diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index 7b735e9b..a4d3c66e 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -2,7 +2,7 @@ const assert = require("assert").strict; const lib = require("@clusterio/lib"); -const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller"); +const { Controller, ControlConnection } = require("@clusterio/controller"); describe("messages/controller", function() { /** @type {Controller} */ @@ -17,7 +17,7 @@ describe("messages/controller", function() { lib.Address.fromShorthand({ controlId: 1 }), ); controller = new Controller(lib.logger, [], controllerConfig); - const user = new ControllerUser(controller.userManager, undefined, "test"); + const user = controller.users.getOrCreateUser("test"); controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); }); diff --git a/test/messages/mod.test.js b/test/messages/mod.test.js index 47bbaafb..4810a90d 100644 --- a/test/messages/mod.test.js +++ b/test/messages/mod.test.js @@ -6,7 +6,7 @@ const { ModDependencyResolveRequest, ModDependency, ModInfo } = lib; const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); -const { Controller, ControlConnection, ControllerUser } = require("@clusterio/controller"); +const { Controller, ControlConnection } = require("@clusterio/controller"); const { slowTest } = require("../integration"); describe("messages/mod", function() { @@ -58,7 +58,7 @@ describe("messages/mod", function() { lib.Address.fromShorthand({ controlId: 1 }), ); controller = new Controller(lib.logger, [], controllerConfig); - const user = new ControllerUser(controller.userManager, undefined, "test"); + const user = controller.users.getOrCreateUser("test"); controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); }); diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js index ef76fbbc..5b83bc8f 100644 --- a/test/messages/plugin.test.js +++ b/test/messages/plugin.test.js @@ -2,7 +2,7 @@ const assert = require("assert").strict; const lib = require("@clusterio/lib"); -const { Controller, ControlConnection, ControllerUser, BaseControllerPlugin } = require("@clusterio/controller"); +const { Controller, ControlConnection } = require("@clusterio/controller"); const { Host } = require("@clusterio/host"); describe("messages/plugin", function() { @@ -20,7 +20,7 @@ describe("messages/plugin", function() { lib.Address.fromShorthand({ controlId: 1 }), ); controller = new Controller(lib.logger, [], controllerConfig); - const user = new ControllerUser(controller.userManager, undefined, "test"); + const user = controller.users.getOrCreateUser("test"); controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); const hostConfig = new lib.HostConfig("host"); diff --git a/test/mock.js b/test/mock.js index 0afd1e77..d505f7f1 100644 --- a/test/mock.js +++ b/test/mock.js @@ -4,9 +4,7 @@ const http = require("http"); const express = require("express"); const lib = require("@clusterio/lib"); - -const UserManager = require("@clusterio/controller/dist/node/src/UserManager").default; -const ControllerUser = require("@clusterio/controller/dist/node/src/ControllerUser").default; +const { User, UserManager, UserRecord, InstanceManager } = require("@clusterio/controller"); const addr = lib.Address.fromShorthand; @@ -179,15 +177,20 @@ class MockController { ])); this.roles = new lib.SubscribableDatastore(roleProvider, roleProvider.value); - this.roles.get(1).grantDefaultPermissions(); + this.roles.get(1).grantDefaultPermissions(); // Todo replace "core.admin" above with grantAdminPermissions - this.userManager = new UserManager(this.config, this.roles); - this.userManager.users = new Map([ - ["test", ControllerUser.fromJSON({ name: "test", roles: [0, 1] }, this.roles)], - ["player", ControllerUser.fromJSON({ name: "player", roles: [1] }, this.roles)], - ]); + const userProvider = new lib.MemoryDatastoreProvider(new Map([ + ["test", UserRecord.fromJSON({ name: "test", roles: [0, 1] })], + ["player", UserRecord.fromJSON({ name: "player", roles: [1] })], + ])); + + this.users = new UserManager( + new lib.SubscribableDatastore(userProvider, userProvider.value), + this.roles, this.config + ); + + this.instances = new InstanceManager(new lib.KeyValueDatastore(), this); - this.instances = new lib.KeyValueDatastore(); this.hosts = new lib.KeyValueDatastore(); this.handles = new Map(); }