Files

261 lines
11 KiB
TypeScript

import { EventEmitter } from 'node:events';
import { createSocket, type Socket } from 'node:dgram';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { rconConfigured } from '../../src/lib/server/config.js';
import { getServer, type ServerRow } from '../../src/lib/server/fleet/servers.js';
import { latestGameSubmission } from '../../src/lib/server/fleet/lifecycle-evidence.js';
import {
rconGetPlayerCount,
rconPlutoniumVersion,
rconStatusResult
} from '../../src/lib/server/integrations/rcon.js';
import { nativeInstance, nativeRequest } from '../../src/lib/server/backends/native.js';
vi.mock('node:dgram', () => ({ createSocket: vi.fn() }));
vi.mock('../../src/lib/server/config.js', () => ({
config: { rcon: { host: 'localhost', password: 'test', timeoutMs: 1_000 } },
rconConfigured: vi.fn()
}));
vi.mock('../../src/lib/server/fleet/servers.js', () => ({
getServer: vi.fn(),
setServerField: vi.fn()
}));
vi.mock('../../src/lib/server/fleet/lifecycle-evidence.js', () => ({
latestGameSubmission: vi.fn()
}));
vi.mock('../../src/lib/server/backends/native.js', () => ({
nativeInstance: vi.fn(),
nativeRequest: vi.fn()
}));
class FakeSocket extends EventEmitter {
send = vi.fn();
close = vi.fn();
}
let sockets: FakeSocket[];
let server: ServerRow;
const packet = (body: string) =>
Buffer.concat([Buffer.from([255, 255, 255, 255]), Buffer.from(body)]);
const reply = (index = 0) =>
sockets[index].emit('message', packet('statusResponse\n\\mapname\\mp_kino\n10 45 "joe"'));
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
sockets = [];
server = {
name: 'test-server',
created_at: '2026-10-01T00:00:00Z',
game_port: 28960,
rcon_host: null
} as ServerRow;
vi.mocked(getServer).mockImplementation(() => ({ ...server }));
vi.mocked(latestGameSubmission).mockReturnValue(null);
vi.mocked(rconConfigured).mockReturnValue(true);
vi.mocked(createSocket).mockImplementation(() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as Socket;
});
});
afterEach(() => {
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});
describe('RCON status transport', () => {
it('keeps reply bodies out of public failure summaries and redacts UDP diagnostics', async () => {
const request = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
sockets[0].emit('message', packet('print\nplayer 192.0.2.123:28960 unexpected response'));
const result = await request;
expect(result).toMatchObject({ ok: false, reason: 'bad-reply' });
if (result.ok) throw new Error('Expected an unrecognized reply');
expect(result.detail).not.toContain('player');
expect(result.detail).not.toContain('192.0.2');
const hex = result
.rawReply!.split('\n')
.map((line) => line.slice(6, 53).trim())
.join(' ')
.replaceAll(' ', '');
const decoded = Buffer.from(hex, 'hex').toString();
expect(decoded).toContain('[redacted]');
expect(decoded).not.toContain('192.0.2');
});
it.each([
['timeout', 'timeout', 'Agent received no reply'],
['unreachable', 'send-error', 'Agent could not reach'],
['unconfigured', 'no-config', 'Agent RCON password'],
['authentication', 'bad-reply', 'wrong RCON password'],
['invalid-reply', 'bad-reply', 'Agent RCON reply is missing']
])('preserves the native agent %s diagnosis', async (agentReason, reason, detail) => {
server.native_instance_id = 'instance';
vi.mocked(nativeInstance).mockReturnValue({
id: 'instance',
generation: 1,
host_id: 'host',
port: 28960
} as never);
vi.mocked(nativeRequest).mockResolvedValue({ ok: false, reason: agentReason, text: '' });
expect(await rconStatusResult(server.name)).toMatchObject({
ok: false,
reason,
detail: expect.stringContaining(detail)
});
expect(nativeRequest).toHaveBeenCalledOnce();
expect(sockets).toHaveLength(0);
});
it('uses the successful native reply after agent-side recovery', async () => {
server.native_instance_id = 'instance';
vi.mocked(nativeInstance).mockReturnValue({
id: 'instance',
generation: 1,
host_id: 'host',
port: 28960
} as never);
vi.mocked(nativeRequest).mockResolvedValue({
ok: true,
text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n'
});
expect(await rconStatusResult(server.name)).toMatchObject({
ok: true,
status: { players: 0, map: 'mp_kino' }
});
});
it('shares overlapping health and occupancy probes and probes again after completion', async () => {
const health = rconStatusResult(server.name);
const occupancy = rconGetPlayerCount(server.name);
const duplicate = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
expect(sockets).toHaveLength(1);
expect(sockets[0].send.mock.calls[0][0].toString()).toContain('rcon test status\n');
reply();
expect(await health).toMatchObject({ ok: true, status: { players: 1, map: 'mp_kino' } });
expect(await health).not.toHaveProperty('rawReply');
expect(await occupancy).toBe(1);
expect(await duplicate).toEqual(await health);
expect(sockets[0].close).toHaveBeenCalledOnce();
const fresh = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
expect(sockets).toHaveLength(2);
reply(1);
expect(await fresh).toMatchObject({ ok: true });
});
it('recovers a dropped UDP reply after the first backoff', async () => {
const request = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(1_000);
expect(sockets).toHaveLength(1);
expect(sockets[0].close).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(249);
expect(sockets).toHaveLength(1);
await vi.advanceTimersByTimeAsync(1);
expect(sockets).toHaveLength(2);
reply(1);
expect(await request).toMatchObject({ ok: true });
});
it('bounds retries at three attempts with 250ms then 500ms backoffs', async () => {
const request = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(1_250);
expect(sockets).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1_499);
expect(sockets).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
expect(sockets).toHaveLength(3);
await vi.advanceTimersByTimeAsync(1_000);
expect(await request).toMatchObject({ ok: false, reason: 'timeout' });
expect(sockets.every((socket) => socket.close.mock.calls.length === 1)).toBe(true);
const fresh = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
expect(sockets).toHaveLength(4);
reply(3);
expect(await fresh).toMatchObject({ ok: true });
});
it.each(['event', 'callback'])(
'retries transient send errors reported through the %s',
async (source) => {
const request = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
if (source === 'event') sockets[0].emit('error', new Error('UDP unavailable'));
else sockets[0].send.mock.calls[0][5](new Error('UDP unavailable'));
await vi.advanceTimersByTimeAsync(250);
expect(sockets).toHaveLength(2);
reply(1);
expect(await request).toMatchObject({ ok: true });
}
);
it.each([
['missing prefix', Buffer.from('wrong listener')],
['wrong password', packet('print\nBad rcon_password')],
['unknown dialect', packet('unexpected reply')]
])('does not retry a %s reply', async (_name, response) => {
const request = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
sockets[0].emit('message', response);
expect(await request).toMatchObject({ ok: false, reason: 'bad-reply' });
expect(sockets).toHaveLength(1);
});
it('does not retry missing configuration or endpoint failures', async () => {
vi.mocked(rconConfigured).mockReturnValue(false);
expect(await rconStatusResult(server.name)).toMatchObject({
ok: false,
reason: 'no-config'
});
server.game_port = null;
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-port' });
vi.mocked(getServer).mockReturnValue(undefined);
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-port' });
expect(sockets).toHaveLength(0);
});
it('cleans up a rejected in-flight probe so the next one can proceed', async () => {
vi.mocked(createSocket).mockImplementationOnce(() => {
throw new Error('Socket initialization failed');
});
await expect(rconStatusResult(server.name)).rejects.toThrow('Socket initialization failed');
const request = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
reply();
expect(await request).toMatchObject({ ok: true });
});
it.each(['restart', 'registration', 'host', 'port', 'server'])(
'does not share probes across a changed %s',
async (change) => {
const original = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
if (change === 'restart')
vi.mocked(latestGameSubmission).mockReturnValue('2026-10-02T00:00:00Z');
if (change === 'registration') server.created_at = '2026-10-02T00:00:00Z';
if (change === 'host') server.rcon_host = 'new-host';
if (change === 'port') server.game_port = 28961;
if (change === 'server') server.name = 'other-server';
const current = rconStatusResult(server.name);
await vi.advanceTimersByTimeAsync(0);
expect(sockets).toHaveLength(2);
reply();
sockets[1].emit('message', packet('statusResponse\n\\mapname\\mp_ascension\n'));
expect(await original).toMatchObject({ ok: true, status: { players: 1 } });
expect(await current).toMatchObject({ ok: true, status: { players: 0 } });
}
);
it('leaves other RCON commands on their existing single-attempt behavior', async () => {
const request = rconPlutoniumVersion(server.name);
await vi.advanceTimersByTimeAsync(1_000);
expect(await request).toMatchObject({ revision: null, error: 'no reply within 1000ms' });
expect(sockets).toHaveLength(1);
});
});