From 76391328ecdea1b4f0f8d0eea8edb32ba1391b6b Mon Sep 17 00:00:00 2001 From: max bittker Date: Mon, 12 Oct 2026 14:30:11 -0400 Subject: [PATCH] fix(engine): allow staff ::kick and ::ban regardless of NODE_PRODUCTION Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- .../game/client/handler/ClientCheatHandler.ts | 8 +-- .../engine/test/fixtures/live-moderation.ts | 63 +++++++++++++++++++ server/engine/test/live-moderation.test.ts | 20 ++++++ 4 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 server/engine/test/fixtures/live-moderation.ts create mode 100644 server/engine/test/live-moderation.test.ts diff --git a/package.json b/package.json index b3254ffd2..44d4c0988 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "docs:api": "bun sdk/generate-api-docs.ts", "docs:api:check": "bun sdk/generate-api-docs.ts --check", "fetch-collision-data": "bun sdk/fetch-collision-data.ts", - "test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/webclient/src/mapview server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/engine/test/reconnect.test.ts server/engine/test/worldfeed.test.ts server/gateway/state-fanout.test.ts server/engine/test/hiscores-performance.test.ts server/engine/test/miscellania-coffers.test.ts", + "test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/webclient/src/mapview server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/engine/test/reconnect.test.ts server/engine/test/worldfeed.test.ts server/gateway/state-fanout.test.ts server/engine/test/hiscores-performance.test.ts server/engine/test/miscellania-coffers.test.ts server/engine/test/live-moderation.test.ts", "typecheck": "tsc --noEmit", "typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json", "test:market:integration": "cd server/engine && bun test/market-integration.ts && bun test/market-recovery.ts && bun test/market-protocol.ts && bun test/market-disabled.ts" diff --git a/server/engine/src/network/game/client/handler/ClientCheatHandler.ts b/server/engine/src/network/game/client/handler/ClientCheatHandler.ts index e1debea59..08b730da8 100644 --- a/server/engine/src/network/game/client/handler/ClientCheatHandler.ts +++ b/server/engine/src/network/game/client/handler/ClientCheatHandler.ts @@ -633,8 +633,8 @@ export default class ClientCheatHandler extends ClientGameMessageHandler player.messageGame('Usage: ::ban '); @@ -659,8 +659,8 @@ export default class ClientCheatHandler extends ClientGameMessageHandler player.messageGame('Usage: ::kick '); diff --git a/server/engine/test/fixtures/live-moderation.ts b/server/engine/test/fixtures/live-moderation.ts new file mode 100644 index 000000000..742791122 --- /dev/null +++ b/server/engine/test/fixtures/live-moderation.ts @@ -0,0 +1,63 @@ +import { afterEach, expect, mock, spyOn, test } from 'bun:test'; +import { EventEmitter } from 'node:events'; + +class TestWorker extends EventEmitter { + postMessage() {} +} +mock.module('worker_threads', () => ({ Worker: TestWorker })); + +const { default: World } = await import('../../src/engine/World.js'); +const { default: Environment } = await import('../../src/util/Environment.js'); +const { default: ClientCheatHandler } = await import('../../src/network/game/client/handler/ClientCheatHandler.js'); +const { default: ClientCheat } = await import('../../src/network/game/client/model/ClientCheat.js'); +const { NetworkPlayer } = await import('../../src/engine/entity/NetworkPlayer.js'); +const { default: WSClientSocket } = await import('../../src/server/ws/WSClientSocket.js'); + +const originalProduction = Environment.node.production; +afterEach(() => { + mock.restore(); + Environment.node.production = originalProduction; +}); + +function staff(level: number) { + return { staffModLevel: level, username: 'moderator', addSessionLog: mock(() => {}), messageGame: mock(() => {}) } as any; +} + +for (const production of [false, true]) { + test(`staff can kick through the normal logout path, production=${production}`, () => { + Environment.node.production = production; + const client = new WSClientSocket(); + const target = new NetworkPlayer('target', 1n, 1n, client); + const logout = spyOn(target, 'logout').mockImplementation(() => {}); + const close = spyOn(client, 'close').mockImplementation(() => {}); + spyOn(World, 'getPlayerByUsername').mockReturnValue(target); + new ClientCheatHandler().handle(new ClientCheat('kick target'), staff(2)); + expect(target.loggingOut).toBe(true); + expect(logout).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + }); + + test(`staff can ban immediately, production=${production}`, () => { + Environment.node.production = production; + const ban = spyOn(World, 'notifyPlayerBan').mockImplementation(() => {}); + const before = Date.now(); + new ClientCheatHandler().handle(new ClientCheat('ban target 60'), staff(2)); + expect(ban).toHaveBeenCalledTimes(1); + expect(ban.mock.calls[0][0]).toBe('moderator'); + expect(ban.mock.calls[0][1]).toBe('target'); + expect(ban.mock.calls[0][2]).toBeGreaterThanOrEqual(before + 60 * 60_000); + }); + + test(`players below moderator level cannot kick or ban, production=${production}`, () => { + Environment.node.production = production; + const lookup = spyOn(World, 'getPlayerByUsername'); + const ban = spyOn(World, 'notifyPlayerBan'); + const handler = new ClientCheatHandler(); + for (const level of [0, 1]) { + handler.handle(new ClientCheat('kick target'), staff(level)); + handler.handle(new ClientCheat('ban target 60'), staff(level)); + } + expect(lookup).not.toHaveBeenCalled(); + expect(ban).not.toHaveBeenCalled(); + }); +} diff --git a/server/engine/test/live-moderation.test.ts b/server/engine/test/live-moderation.test.ts new file mode 100644 index 000000000..a27b05d0c --- /dev/null +++ b/server/engine/test/live-moderation.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'bun:test'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +test('live moderation preserves staff authorization in either production mode', () => { + const dir = mkdtempSync(join(tmpdir(), 'live-moderation-')); + try { + mkdirSync(join(dir, 'data/config'), { recursive: true }); + copyFileSync(join(import.meta.dir, '../data/config/private.pem'), join(dir, 'data/config/private.pem')); + const result = Bun.spawnSync([process.execPath, 'test', join(import.meta.dir, 'fixtures/live-moderation.ts')], { + cwd: dir, + env: { ...process.env, GE_DATABASE: join(dir, 'market.sqlite') } + }); + if (result.exitCode !== 0) throw new Error(result.stdout.toString() + result.stderr.toString()); + expect(result.exitCode).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}, 15000);