261 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|