From 4f5b99ac982769dcaacdf7fe55b969be861f39f6 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 15:28:37 -0400 Subject: [PATCH] feature: per-server plugin settings --- hasura/metadata/actions.graphql | 7 + hasura/metadata/actions.yaml | 3 +- .../tables/public_server_plugin_configs.yaml | 21 ++ .../default/tables/public_servers.yaml | 7 + .../databases/default/tables/tables.yaml | 1 + .../down.sql | 1 + .../up.sql | 14 ++ .../dedicated-server-config.service.ts | 84 +++++++- .../dedicated-servers.controller.ts | 11 ++ .../dedicated-servers.service.spec.ts | 24 ++- .../dedicated-servers.service.ts | 20 +- src/game-plugins/game-modes.service.ts | 85 +++++++- test/game-mode-resolution.spec.ts | 57 ++++++ test/server-plugin-configs.spec.ts | 182 ++++++++++++++++++ 14 files changed, 507 insertions(+), 10 deletions(-) create mode 100644 hasura/metadata/databases/default/tables/public_server_plugin_configs.yaml create mode 100644 hasura/migrations/default/1889000001800_server_plugin_configs/down.sql create mode 100644 hasura/migrations/default/1889000001800_server_plugin_configs/up.sql create mode 100644 test/server-plugin-configs.spec.ts diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 376b0850..c28d4376 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -2697,6 +2697,12 @@ input ServerPluginInput { enabled: Boolean! } +input ServerPluginConfigInput { + slug: String! + cfg: String + config: jsonb +} + type ImportedWorkshopMap { id: uuid! name: String! @@ -2725,6 +2731,7 @@ type Mutation { server_id: uuid! map_rotation: ServerMapRotationInput plugins: [ServerPluginInput!] + plugin_configs: [ServerPluginConfigInput!] access: ServerAccessInput ): SuccessOutput } diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index ee1a629a..502f6853 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -2058,7 +2058,7 @@ actions: forward_client_headers: true permissions: - role: administrator - comment: Save a community server's rotation, plugins and access, restarting it at most once + comment: Save a community server's rotation, plugins, plugin configs and access, restarting it at most once - name: moveDedicatedServerToNode definition: kind: synchronous @@ -2088,6 +2088,7 @@ custom_types: - name: UtilityPlaybookStepInput - name: UtilitySightlinePairInput - name: ServerPluginInput + - name: ServerPluginConfigInput - name: ServerMapRotationInput - name: ServerAccessInput objects: diff --git a/hasura/metadata/databases/default/tables/public_server_plugin_configs.yaml b/hasura/metadata/databases/default/tables/public_server_plugin_configs.yaml new file mode 100644 index 00000000..d91b2acb --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_server_plugin_configs.yaml @@ -0,0 +1,21 @@ +table: + name: server_plugin_configs + schema: public +object_relationships: + - name: plugin + using: + foreign_key_constraint_on: plugin_slug + - name: server + using: + foreign_key_constraint_on: server_id +select_permissions: + - role: administrator + permission: + columns: + - server_id + - plugin_slug + - cfg + - config + - updated_at + filter: {} + comment: "" diff --git a/hasura/metadata/databases/default/tables/public_servers.yaml b/hasura/metadata/databases/default/tables/public_servers.yaml index e4317d0a..59e1590b 100644 --- a/hasura/metadata/databases/default/tables/public_servers.yaml +++ b/hasura/metadata/databases/default/tables/public_servers.yaml @@ -69,6 +69,13 @@ array_relationships: table: name: server_plugins schema: public + - name: plugin_configs + using: + foreign_key_constraint_on: + column: server_id + table: + name: server_plugin_configs + schema: public computed_fields: - name: connection_link definition: diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index d31b4f05..1be6f992 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -183,6 +183,7 @@ - "!include public_server_map_rotation.yaml" - "!include public_server_migrations.yaml" - "!include public_server_player_sessions.yaml" +- "!include public_server_plugin_configs.yaml" - "!include public_server_plugins.yaml" - "!include public_server_recent_players.yaml" - "!include public_server_regions.yaml" diff --git a/hasura/migrations/default/1889000001800_server_plugin_configs/down.sql b/hasura/migrations/default/1889000001800_server_plugin_configs/down.sql new file mode 100644 index 00000000..3777292e --- /dev/null +++ b/hasura/migrations/default/1889000001800_server_plugin_configs/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.server_plugin_configs; diff --git a/hasura/migrations/default/1889000001800_server_plugin_configs/up.sql b/hasura/migrations/default/1889000001800_server_plugin_configs/up.sql new file mode 100644 index 00000000..5cb4db3a --- /dev/null +++ b/hasura/migrations/default/1889000001800_server_plugin_configs/up.sql @@ -0,0 +1,14 @@ +-- A plugin's cvars and config file set for one community server, layered over +-- what the plugin's own page sets for every server that loads it. +CREATE TABLE IF NOT EXISTS public.server_plugin_configs ( + server_id uuid NOT NULL, + plugin_slug text NOT NULL, + cfg text, + config jsonb, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (server_id, plugin_slug), + CONSTRAINT server_plugin_configs_server_fkey FOREIGN KEY (server_id) + REFERENCES public.servers (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT server_plugin_configs_plugin_fkey FOREIGN KEY (plugin_slug) + REFERENCES public.game_plugins (slug) ON UPDATE CASCADE ON DELETE CASCADE +); diff --git a/src/dedicated-servers/dedicated-server-config.service.ts b/src/dedicated-servers/dedicated-server-config.service.ts index 2dadd9c1..6cb7327d 100644 --- a/src/dedicated-servers/dedicated-server-config.service.ts +++ b/src/dedicated-servers/dedicated-server-config.service.ts @@ -45,6 +45,11 @@ export class DedicatedServerConfigService { settings: { mapRotation: { mapIds: Array; shuffle: boolean } | null; plugins: Array<{ slug: string; enabled: boolean }> | null; + pluginConfigs?: Array<{ + slug: string; + cfg: string | null; + config: unknown; + }> | null; access: { restricted: boolean; minRole: string | null; @@ -66,11 +71,17 @@ export class DedicatedServerConfigService { await this.setPlugins(serverId, settings.plugins, { restart: false }); } + if (settings.pluginConfigs) { + await this.setPluginConfigs(serverId, settings.pluginConfigs, { + restart: false, + }); + } + if (settings.access) { await this.setAccess(serverId, settings.access); } - if (settings.mapRotation || settings.plugins) { + if (settings.mapRotation || settings.plugins || settings.pluginConfigs) { await this.restart(await this.communityServer(serverId)); } } @@ -161,6 +172,77 @@ export class DedicatedServerConfigService { } } + // One server's layer over what the plugin's own page sets for every server. + // Only the entries sent are touched, and one with neither cvars nor a file + // drops the server back to the plugin page's. + public async setPluginConfigs( + serverId: string, + configs: Array<{ slug: string; cfg: string | null; config: unknown }>, + options: { restart?: boolean } = {}, + ): Promise { + const server = await this.communityServer(serverId); + + const plugins = new Map( + ( + await this.postgres.query< + Array<{ slug: string; name: string; config_path: string | null }> + >( + `SELECT slug, name, config_path FROM game_plugins + WHERE slug = ANY($1::text[])`, + [configs.map((entry) => entry.slug)], + ) + ).map((plugin) => [plugin.slug, plugin]), + ); + + for (const entry of configs) { + const plugin = plugins.get(entry.slug); + + if (!plugin) { + throw new BadRequestException(`${entry.slug} is not in the catalog`); + } + + if (entry.config != null && !plugin.config_path) { + throw new BadRequestException(`${plugin.name} has no config file`); + } + } + + await this.postgres.transaction(async (client) => { + for (const entry of configs) { + const cfg = entry.cfg?.trim() ? entry.cfg : null; + const config = entry.config ?? null; + + if (cfg === null && config === null) { + await client.query( + `DELETE FROM server_plugin_configs + WHERE server_id = $1 AND plugin_slug = $2`, + [serverId, entry.slug], + ); + continue; + } + + await client.query( + `INSERT INTO server_plugin_configs + (server_id, plugin_slug, cfg, config, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (server_id, plugin_slug) DO UPDATE SET + cfg = EXCLUDED.cfg, + config = EXCLUDED.config, + updated_at = now()`, + [ + serverId, + entry.slug, + cfg, + config === null ? null : JSON.stringify(config), + ], + ); + } + }); + + if (options.restart !== false) { + await this.restart(server); + } + } + // Enforced by the Player Management plugin, which rereads the list on a // refresh, so unlike the pod settings above this needs no restart and works // on external servers too. diff --git a/src/dedicated-servers/dedicated-servers.controller.ts b/src/dedicated-servers/dedicated-servers.controller.ts index 45eb9070..09607dc6 100644 --- a/src/dedicated-servers/dedicated-servers.controller.ts +++ b/src/dedicated-servers/dedicated-servers.controller.ts @@ -177,6 +177,11 @@ export class DedicatedServersController { server_id: string; map_rotation?: { map_ids: Array; shuffle: boolean } | null; plugins?: Array<{ slug: string; enabled: boolean }> | null; + plugin_configs?: Array<{ + slug: string; + cfg?: string | null; + config?: unknown; + }> | null; access?: { restricted: boolean; min_role?: string | null; @@ -194,6 +199,12 @@ export class DedicatedServersController { } : null, plugins: data.plugins ?? null, + pluginConfigs: + data.plugin_configs?.map((entry) => ({ + slug: entry.slug, + cfg: entry.cfg ?? null, + config: entry.config ?? null, + })) ?? null, access: data.access ? { restricted: data.access.restricted, diff --git a/src/dedicated-servers/dedicated-servers.service.spec.ts b/src/dedicated-servers/dedicated-servers.service.spec.ts index e94e6652..9fe16db6 100644 --- a/src/dedicated-servers/dedicated-servers.service.spec.ts +++ b/src/dedicated-servers/dedicated-servers.service.spec.ts @@ -689,6 +689,23 @@ describe("DedicatedServersService.withServerCfg", () => { }); }); + // A server's own value for a plugin is the most specific, so it lands last. + it("runs a server's own plugin cvars after the mode's", () => { + expect( + files( + DedicatedServersService.withServerCfg( + mode(), + "Custom" as never, + [{ slug: "deathmatch", cfg: "dm_replenish_health 10" }], + [{ slug: "deathmatch", cfg: "dm_replenish_health 50" }], + ), + ), + ).toEqual({ + "cfg/gamemode_deathmatch_server.cfg": + "dm_replenish_health 10\nmp_teammates_are_enemies 1\ndm_replenish_health 50\n", + }); + }); + it("writes plugin cvars on a server whose mode has none of its own", () => { expect( files( @@ -732,6 +749,11 @@ describe("DedicatedServersService.setupDedicatedServer", () => { .mockResolvedValue([ { slug: "inventory-simulator", cfg: inventoryCfg }, ]), + serverCfgLayers: jest + .fn() + .mockResolvedValue([ + { slug: "inventory-simulator", cfg: "invsim_ws_enabled 0" }, + ]), environmentFor: GameModesService.prototype.environmentFor, }; @@ -792,7 +814,7 @@ describe("DedicatedServersService.setupDedicatedServer", () => { // only a match execs plugin cvars, so it ran on the plugin's own defaults. it("gives a community server the cvars of the plugins it loads", async () => { expect(await setup("Casual")).toEqual({ - "cfg/gamemode_casual_server.cfg": `${inventoryCfg}\n`, + "cfg/gamemode_casual_server.cfg": `${inventoryCfg}\ninvsim_ws_enabled 0\n`, }); }); diff --git a/src/dedicated-servers/dedicated-servers.service.ts b/src/dedicated-servers/dedicated-servers.service.ts index c4e690d2..19cfc333 100644 --- a/src/dedicated-servers/dedicated-servers.service.ts +++ b/src/dedicated-servers/dedicated-servers.service.ts @@ -236,12 +236,17 @@ export class DedicatedServersService { // Ranked and Practice servers host matches, and a match execs each // plugin's cvars itself. + const community = server.type !== "Ranked" && server.type !== "Practice"; + const gameMode = DedicatedServersService.withServerCfg( resolvedMode, server.type, - server.type === "Ranked" || server.type === "Practice" - ? [] - : await this.gameModesService.pluginCfgLayers(resolvedMode), + community + ? await this.gameModesService.pluginCfgLayers(resolvedMode) + : [], + community + ? await this.gameModesService.serverCfgLayers(serverId, resolvedMode) + : [], ); const gameModeEnvironment = @@ -746,18 +751,23 @@ export class DedicatedServersService { // A community server has no match, so the cvars a match would exec -- each // loading plugin's, then the mode's -- ride in as the server config CS2 // execs after the Valve mode's own, on every map load, which is also what - // keeps them across a rotation. + // keeps them across a rotation. The server's own plugin cvars run last. public static withServerCfg( mode: ResolvedGameMode | null, type: e_server_types_enum, pluginCfgs: Array<{ slug: string; cfg: string }> = [], + serverCfgs: Array<{ slug: string; cfg: string }> = [], ): ResolvedGameMode | null { const serverCfg = DedicatedServersService.valveModeFor( type, mode?.valveMode, ).serverCfg; - const cfg = [...pluginCfgs.map((layer) => layer.cfg), mode?.cfg ?? ""] + const cfg = [ + ...pluginCfgs.map((layer) => layer.cfg), + mode?.cfg ?? "", + ...serverCfgs.map((layer) => layer.cfg), + ] .filter((block) => block.trim()) .map((block) => (block.endsWith("\n") ? block : `${block}\n`)) .join(""); diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index 95652e04..29284153 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -156,8 +156,12 @@ export class GameModesService { } if (!matchId && row?.is_community_server) { - return await this.withInstallConfigs( - await this.withServerPlugins(serverId, mode, scope), + return await this.withServerConfigs( + serverId, + await this.withInstallConfigs( + await this.withServerPlugins(serverId, mode, scope), + scope.runtime, + ), scope.runtime, ); } @@ -647,6 +651,83 @@ export class GameModesService { }; } + // A community server's own config for a plugin is the most specific word on + // it, so it is laid over the plugin page's, the mode's and the rotation's. + private async withServerConfigs( + serverId: string, + resolved: ResolvedGameMode | null, + runtime: string, + ): Promise { + const slugs = GameModesService.entriesOf(resolved).map( + GameModesService.slugOf, + ); + + if (!resolved || slugs.length === 0) { + return resolved; + } + + const rows = await this.postgres.query< + Array<{ config: unknown; config_path: string }> + >( + `SELECT c.config, p.config_path + FROM server_plugin_configs c + INNER JOIN game_plugins p ON p.slug = c.plugin_slug + WHERE c.server_id = $1 + AND c.plugin_slug = ANY($2::text[]) + AND c.config IS NOT NULL + AND p.config_path IS NOT NULL`, + [serverId, slugs], + ); + + if (rows.length === 0) { + return resolved; + } + + return { + ...resolved, + pluginConfigs: GameModesService.withConfigFiles( + resolved.pluginConfigs, + Object.fromEntries( + rows.map((row) => [ + row.config_path.replace("{runtime}", runtime), + JSON.stringify(row.config, null, 2), + ]), + ), + ), + }; + } + + // One server's cvars for the plugins it loads, run after everything else + // that sets them on that server. + public async serverCfgLayers( + serverId: string, + mode: ResolvedGameMode | null, + ): Promise> { + const slugs = GameModesService.entriesOf(mode).map(GameModesService.slugOf); + + if (slugs.length === 0) { + return []; + } + + const rows = await this.postgres.query< + Array<{ plugin_slug: string; cfg: string }> + >( + `SELECT plugin_slug, cfg + FROM server_plugin_configs + WHERE server_id = $1 + AND plugin_slug = ANY($2::text[]) + AND cfg IS NOT NULL + AND btrim(cfg) <> ''`, + [serverId, slugs], + ); + + const cfgs = new Map(rows.map((row) => [row.plugin_slug, row.cfg])); + + return slugs + .filter((slug) => cfgs.has(slug)) + .map((slug) => ({ slug, cfg: cfgs.get(slug) as string })); + } + private static configFilesOf( mode: ResolvedGameMode | null, ): Record { diff --git a/test/game-mode-resolution.spec.ts b/test/game-mode-resolution.spec.ts index 364b1fab..fe232433 100644 --- a/test/game-mode-resolution.spec.ts +++ b/test/game-mode-resolution.spec.ts @@ -939,6 +939,63 @@ describe("game mode resolution (SQL-driven)", () => { ]); }); + // One server's rotation is not every server's: its own file wins over + // the plugin page's, and the cvar still points at it. + it("writes a server's own file over the plugin page's", async () => { + const own = [{ name: "Pistols", weapons: ["deagle"], duration: 60 }]; + await postgres.query( + `UPDATE game_plugin_installs SET config = $1 WHERE plugin_slug = 'deathmatch'`, + [JSON.stringify(modes)], + ); + await postgres.query( + `INSERT INTO server_plugin_configs (server_id, plugin_slug, config) + VALUES ($1, 'deathmatch', $2)`, + [publicServer, JSON.stringify(own)], + ); + + const resolved = await service.resolveForServer(publicServer); + + expect(files(resolved?.pluginConfigs ?? null)[path]).toEqual(own); + expect(await service.pluginCfgLayers(resolved)).toEqual([ + { slug: "deathmatch", cfg: `dm_modes_file "${path}"` }, + ]); + }); + + it("writes a server's own file with nothing on the plugin page", async () => { + const own = [{ name: "Pistols", weapons: ["deagle"], duration: 60 }]; + await postgres.query( + `INSERT INTO server_plugin_configs (server_id, plugin_slug, config) + VALUES ($1, 'deathmatch', $2)`, + [publicServer, JSON.stringify(own)], + ); + + const resolved = await service.resolveForServer(publicServer); + + expect(files(resolved?.pluginConfigs ?? null)[path]).toEqual(own); + }); + + it("hands back a server's own cvars for the plugins it loads", async () => { + const otherServer = await community(27200); + await postgres.query( + `INSERT INTO server_plugin_configs (server_id, plugin_slug, cfg) + VALUES ($1, 'deathmatch', 'dm_replenish_health 50'), + ($2, 'deathmatch', 'dm_replenish_health 5')`, + [publicServer, otherServer], + ); + + const resolved = await service.resolveForServer(publicServer); + + expect(await service.serverCfgLayers(publicServer, resolved)).toEqual([ + { slug: "deathmatch", cfg: "dm_replenish_health 50" }, + ]); + expect( + await service.serverCfgLayers( + otherServer, + await service.resolveForServer(otherServer), + ), + ).toEqual([]); + }); + // The cvar only ever names a file this server is actually given. it("leaves the cvar alone while the plugin keeps its own file", async () => { const resolved = await service.resolveForServer(publicServer); diff --git a/test/server-plugin-configs.spec.ts b/test/server-plugin-configs.spec.ts new file mode 100644 index 00000000..92d11095 --- /dev/null +++ b/test/server-plugin-configs.spec.ts @@ -0,0 +1,182 @@ +import { BadRequestException } from "@nestjs/common"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { DedicatedServerConfigService } from "./../src/dedicated-servers/dedicated-server-config.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// One community server's layer over what a plugin's page sets for every +// server. Only the plugins sent are touched, and an empty entry drops back to +// the plugin page's. +describe("server plugin configs (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let service: DedicatedServerConfigService; + let rebuild: jest.Mock; + let serverId: string; + + beforeAll(async () => { + db = await bootMigratedDb("ServerPluginConfigs"); + postgres = db.postgres; + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + rebuild = jest.fn().mockResolvedValue(true); + service = new DedicatedServerConfigService( + { log: jest.fn(), warn: jest.fn() } as never, + postgres, + { rebuildDedicatedServer: rebuild } as never, + {} as never, + ); + + await postgres.query("DELETE FROM servers"); + await postgres.query("DELETE FROM game_plugins"); + await postgres.query("DELETE FROM game_server_nodes"); + await postgres.query( + `INSERT INTO server_regions (value, description) + VALUES ('TestRegion', 'TestRegion') ON CONFLICT (value) DO NOTHING`, + ); + await postgres.query( + `INSERT INTO game_server_nodes (id, status, enabled, region) + VALUES ('node-a', 'Online', true, 'TestRegion')`, + ); + + const [server] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, tv_port, region, type, + is_dedicated, enabled, game_server_node_id) + VALUES ('127.0.0.1', 'dm', $1, 27100, 27101, 'TestRegion', 'Custom', + false, true, 'node-a') + RETURNING id`, + [Buffer.from("password")], + ); + await postgres.query( + `UPDATE servers SET is_dedicated = true WHERE id = $1`, + [server.id], + ); + serverId = server.id; + + await postgres.query( + `INSERT INTO game_plugins (slug, kind, name, author, description, config_path) + VALUES ('deathmatch', 'game', 'Deathmatch', 'ianlucas', 'dm', 'modes.json'), + ('inventory-simulator', 'game', 'Inventory', 'ianlucas', 'inv', NULL)`, + ); + }); + + const stored = async () => + await postgres.query>>( + `SELECT plugin_slug, cfg, config FROM server_plugin_configs + ORDER BY plugin_slug`, + ); + + it("stores a server's cvars and file and restarts it once", async () => { + await service.saveSettings(serverId, { + mapRotation: null, + plugins: null, + pluginConfigs: [ + { + slug: "deathmatch", + cfg: "dm_replenish_health 50", + config: [{ name: "Pistols", weapons: ["deagle"], duration: 60 }], + }, + { + slug: "inventory-simulator", + cfg: "invsim_ws_enabled 0", + config: null, + }, + ], + access: null, + }); + + expect(await stored()).toEqual([ + { + plugin_slug: "deathmatch", + cfg: "dm_replenish_health 50", + config: [{ name: "Pistols", weapons: ["deagle"], duration: 60 }], + }, + { + plugin_slug: "inventory-simulator", + cfg: "invsim_ws_enabled 0", + config: null, + }, + ]); + expect(rebuild).toHaveBeenCalledTimes(1); + }); + + it("drops a plugin back to its page's config when the entry is empty", async () => { + await service.setPluginConfigs( + serverId, + [{ slug: "deathmatch", cfg: "dm_replenish_health 50", config: null }], + { restart: false }, + ); + await service.setPluginConfigs( + serverId, + [{ slug: "deathmatch", cfg: " ", config: null }], + { restart: false }, + ); + + expect(await stored()).toEqual([]); + }); + + it("leaves plugins it was not sent alone", async () => { + await service.setPluginConfigs( + serverId, + [ + { slug: "deathmatch", cfg: "dm_replenish_health 50", config: null }, + { + slug: "inventory-simulator", + cfg: "invsim_ws_enabled 0", + config: null, + }, + ], + { restart: false }, + ); + await service.setPluginConfigs( + serverId, + [{ slug: "deathmatch", cfg: "dm_replenish_health 75", config: null }], + { restart: false }, + ); + + expect((await stored()).map((row) => row.cfg)).toEqual([ + "dm_replenish_health 75", + "invsim_ws_enabled 0", + ]); + }); + + it("refuses a file for a plugin that reads none", async () => { + await expect( + service.setPluginConfigs( + serverId, + [{ slug: "inventory-simulator", cfg: null, config: { x: 1 } }], + { restart: false }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(await stored()).toEqual([]); + }); + + it("refuses a plugin that is not in the catalog", async () => { + await expect( + service.setPluginConfigs( + serverId, + [{ slug: "nope", cfg: "x 1", config: null }], + { restart: false }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses a Ranked server, which runs 5Stack's own plugin set", async () => { + await postgres.query(`UPDATE servers SET type = 'Ranked' WHERE id = $1`, [ + serverId, + ]); + + await expect( + service.setPluginConfigs( + serverId, + [{ slug: "deathmatch", cfg: "x 1", config: null }], + { restart: false }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +});