Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
228 lines
12 KiB
TypeScript
228 lines
12 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
import { gameEvent, postTelemetry as post, telemetryAuthHeaders } from '../helpers/telemetry.js';
|
|
|
|
const server = 't5_zombie_theater_1';
|
|
const otherServer = 't5_zombie_theater_2';
|
|
let fixture: Fixture;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture([server, otherServer]);
|
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
|
await seedServerRegistry();
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
async function diagnostics(name = server) {
|
|
const { GET } = await import('../../src/routes/api/servers/[name]/telemetry-requests/+server.js');
|
|
return GET({ params: { name } });
|
|
}
|
|
|
|
it('returns uncached, empty diagnostics for a registered server before any requests arrive', async () => {
|
|
const response = await diagnostics();
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('cache-control')).toContain('no-store');
|
|
expect(await response.json()).toEqual({
|
|
ok: true, requests: [], retention: { maxPerServer: 200, maxAgeDays: 7 }
|
|
});
|
|
expect((await diagnostics('unknown')).status).toBe(404);
|
|
});
|
|
|
|
it('persists the received payload and response, isolates each server, and lists newest requests first', async () => {
|
|
const first = gameEvent('round_change', 1);
|
|
const latest = { ...gameEvent('round_change', 3), scriptVersion: 'diagnostic-version' };
|
|
expect((await post(first)).status).toBe(200);
|
|
expect((await post(gameEvent('round_change', 2, 1, otherServer))).status).toBe(200);
|
|
expect((await post(latest)).status).toBe(200);
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb();
|
|
|
|
const body = await (await diagnostics()).json();
|
|
expect(body.requests).toHaveLength(2);
|
|
expect(body.requests[0]).toEqual({
|
|
id: expect.any(Number), server, receivedAt: expect.any(String), status: 200, error: null,
|
|
event: 'round_change', map: 'zombie_theater', round: 3, playerCount: 4,
|
|
contentType: 'application/json', payload: JSON.stringify(latest), payloadTruncated: false
|
|
});
|
|
expect(body.requests[0].id).toBeGreaterThan(body.requests[1].id);
|
|
expect(body.requests[1].payload).toBe(JSON.stringify(first));
|
|
expect((await (await diagnostics(otherServer)).json()).requests).toEqual([
|
|
expect.objectContaining({ server: otherServer, round: 2, playerCount: 1 })
|
|
]);
|
|
});
|
|
|
|
it('records rejected player data with the same validation reason returned to the telemetry client', async () => {
|
|
const invalid = { ...gameEvent('round_change', 5), players: [{ name: 'Player', guid: '123' }] };
|
|
const response = await post(invalid);
|
|
expect(response.status).toBe(400);
|
|
const result = await response.json();
|
|
expect(result.error).toContain('players[0].kills');
|
|
const { requests } = await (await diagnostics()).json();
|
|
expect(requests).toEqual([expect.objectContaining({
|
|
server, status: 400, error: result.error, event: 'round_change', round: 5,
|
|
playerCount: 1, payload: JSON.stringify(invalid), payloadTruncated: false
|
|
})]);
|
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
expect(listTelemetry()).toEqual([]);
|
|
});
|
|
|
|
it('records identifiable JSON sent with an unsupported or missing Content-Type', async () => {
|
|
const payload = gameEvent('round_change', 2);
|
|
expect((await post(payload, 'text/plain')).status).toBe(415);
|
|
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
|
// A byte body avoids Request automatically assigning text/plain to a string body.
|
|
const request = new Request('http://localhost/api/telemetry', {
|
|
method: 'POST', headers: telemetryAuthHeaders(), body: new TextEncoder().encode(JSON.stringify(payload))
|
|
});
|
|
expect(request.headers.get('content-type')).toBeNull();
|
|
expect((await POST({ request })).status).toBe(415);
|
|
const { requests } = await (await diagnostics()).json();
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({ status: 415, contentType: null, error: 'Content-Type must be application/json' }),
|
|
expect.objectContaining({ status: 415, contentType: 'text/plain', payload: JSON.stringify(payload) })
|
|
]);
|
|
});
|
|
|
|
it('distinguishes a zero round and an empty scoreboard from absent or invalid fields', async () => {
|
|
await post(gameEvent('round_change', 0, 0));
|
|
await post(gameEvent('game_start', 0, 0));
|
|
await post({ server, players: 4 });
|
|
const { requests } = await (await diagnostics()).json();
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({ status: 400, event: null, map: null, round: null, playerCount: null }),
|
|
expect.objectContaining({ status: 200, event: 'game_start', round: null, playerCount: 0 }),
|
|
expect.objectContaining({ status: 200, event: 'round_change', round: 0, playerCount: 0 })
|
|
]);
|
|
});
|
|
|
|
it('keeps unknown and destroyed server rejections and matches the submitted server name exactly', async () => {
|
|
const unknown = 't5_zombie_theater_99';
|
|
const spaced = ` ${server} `;
|
|
expect((await post(gameEvent('game_start', 0, 0, unknown))).status).toBe(404);
|
|
expect((await post(gameEvent('game_start', 0, 0, spaced))).status).toBe(404);
|
|
await post(gameEvent('game_start'));
|
|
const { setServerField } = await import('../../src/lib/server/fleet/servers.js');
|
|
setServerField(server, 'destroyed_at', new Date().toISOString());
|
|
expect((await post(gameEvent('game_end', 1))).status).toBe(404);
|
|
|
|
const { listTelemetryRequests } = await import('../../src/lib/server/fleet/telemetry-requests.js');
|
|
expect(listTelemetryRequests(unknown)).toEqual([expect.objectContaining({ server: unknown, status: 404 })]);
|
|
expect(listTelemetryRequests(spaced)).toEqual([expect.objectContaining({ server: spaced, status: 404 })]);
|
|
expect((await diagnostics(unknown)).status).toBe(404);
|
|
const response = await diagnostics();
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).requests).toEqual([
|
|
expect.objectContaining({ server, status: 404, error: 'Server is not registered or has been destroyed' }),
|
|
expect.objectContaining({ server, status: 200 })
|
|
]);
|
|
});
|
|
|
|
it('does not attribute malformed JSON or unusable server names to a server', async () => {
|
|
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
|
const malformed = new Request('http://localhost/api/telemetry', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...telemetryAuthHeaders() }, body: `{"server":"${server}",`
|
|
});
|
|
expect((await POST({ request: malformed })).status).toBe(400);
|
|
for (const value of [undefined, null, '', ' ', 123, 's'.repeat(256)]) {
|
|
expect((await post({ ...gameEvent('game_start'), server: value })).status).toBe(400);
|
|
}
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests').get()).toEqual({ count: 0 });
|
|
});
|
|
|
|
it('bounds stored payloads and marks truncation without rejecting a valid event', async () => {
|
|
const payload = { ...gameEvent('round_change', 4), diagnostic: 'x'.repeat(20_000) };
|
|
expect((await post(payload)).status).toBe(200);
|
|
const { requests } = await (await diagnostics()).json();
|
|
expect(requests[0]).toMatchObject({ status: 200, round: 4, playerCount: 4, payloadTruncated: true });
|
|
expect(requests[0].payload).toBe(JSON.stringify(payload).slice(0, 16 * 1024));
|
|
});
|
|
|
|
it('records processing failures after rollback without changing the scoreboard, match state, or broadcasts', async () => {
|
|
const first = gameEvent('round_change', 3);
|
|
await post(first);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const beta = await import('../../src/lib/server/fleet/beta-tests.js');
|
|
const snapshot = listTelemetry();
|
|
const matches = db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
vi.spyOn(beta, 'recordGameEvent').mockImplementationOnce(() => { throw new Error('Injected beta processing failure'); });
|
|
const response = await post(gameEvent('game_end', 99));
|
|
unsubscribe();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ ok: false, error: 'Could not process telemetry' });
|
|
expect(listTelemetry()).toEqual(snapshot);
|
|
expect(getServer(server)?.game_ended_at).toBeNull();
|
|
expect(db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all()).toEqual(matches);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect((await (await diagnostics()).json()).requests).toEqual([
|
|
expect.objectContaining({ status: 500, event: 'game_end', round: 99, error: 'Could not process telemetry: Injected beta processing failure' }),
|
|
expect.objectContaining({ status: 200, round: 3 })
|
|
]);
|
|
});
|
|
|
|
it('still accepts and broadcasts telemetry if storing diagnostics fails', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().exec(`CREATE TRIGGER reject_diagnostics BEFORE INSERT ON telemetry_requests
|
|
BEGIN SELECT RAISE(FAIL, 'Injected diagnostic storage failure'); END`);
|
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
const payload = gameEvent('round_change', 7);
|
|
const response = await post(payload);
|
|
unsubscribe();
|
|
|
|
expect(response.status).toBe(200);
|
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
expect(listTelemetry()).toEqual([expect.objectContaining({ ...payload, receivedAt: expect.any(String) })]);
|
|
expect(listener).toHaveBeenCalledWith({
|
|
type: 'server-telemetry', entries: [expect.objectContaining({ ...payload, receivedAt: expect.any(String) })]
|
|
});
|
|
});
|
|
|
|
it('retains only the latest 200 requests for a server and removes requests older than seven days', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { recordTelemetryRequest, listTelemetryRequests } = await import('../../src/lib/server/fleet/telemetry-requests.js');
|
|
const now = new Date().toISOString();
|
|
const old = new Date(Date.now() - 8 * 86_400_000).toISOString();
|
|
const insert = db().prepare(`INSERT INTO telemetry_requests
|
|
(server, received_at, status, payload, payload_truncated) VALUES (?, ?, 200, '{}', 0)`);
|
|
db().transaction(() => {
|
|
insert.run(otherServer, old);
|
|
insert.run(otherServer, now);
|
|
for (let i = 0; i < 205; i++) insert.run(server, now);
|
|
})();
|
|
recordTelemetryRequest({ value: gameEvent('round_change', 9), receivedAt: now, status: 200, error: null, contentType: 'application/json' });
|
|
|
|
const requests = listTelemetryRequests(server);
|
|
expect(requests).toHaveLength(200);
|
|
expect(requests[0]).toMatchObject({ round: 9 });
|
|
expect(requests.map(entry => entry.id)).toEqual([...requests.map(entry => entry.id)].sort((a, b) => b - a));
|
|
expect(listTelemetryRequests(otherServer)).toHaveLength(1);
|
|
expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests WHERE received_at = ?').get(old)).toEqual({ count: 0 });
|
|
});
|
|
|
|
it('caps global diagnostic history even when rejected requests name many unknown servers', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { recordTelemetryRequest } = await import('../../src/lib/server/fleet/telemetry-requests.js');
|
|
const now = new Date().toISOString();
|
|
const insert = db().prepare(`INSERT INTO telemetry_requests
|
|
(server, received_at, status, payload, payload_truncated) VALUES (?, ?, 404, '{}', 0)`);
|
|
db().transaction(() => {
|
|
for (let i = 0; i < 10_005; i++) insert.run(`unknown-${i}`, now);
|
|
})();
|
|
recordTelemetryRequest({ value: gameEvent('game_start'), receivedAt: now, status: 200, error: null, contentType: 'application/json' });
|
|
expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests').get()).toEqual({ count: 10_000 });
|
|
expect(db().prepare('SELECT server FROM telemetry_requests ORDER BY id DESC LIMIT 1').get()).toEqual({ server });
|
|
expect(db().prepare("SELECT id FROM telemetry_requests WHERE server = 'unknown-0'").get()).toBeUndefined();
|
|
});
|