183 lines
8.1 KiB
TypeScript
183 lines
8.1 KiB
TypeScript
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import { JSON_BODY_LIMIT } from '../../src/lib/server/request-body.js';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
import { gameEvent, postTelemetry as post, TEST_TELEMETRY_TOKEN } from '../helpers/telemetry.js';
|
|
|
|
const events = ['game_start', 'round_change', 'heartbeat', 'game_end'] as const;
|
|
const identityLimit = 255;
|
|
let fixture: Fixture;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.stubEnv('EC_TELEMETRY_TOKEN', TEST_TELEMETRY_TOKEN);
|
|
fixture = await newFixture();
|
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
|
await seedServerRegistry();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
async function storedEvidence() {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
return {
|
|
telemetry: db().prepare('SELECT * FROM server_telemetry').all(),
|
|
players: db().prepare('SELECT * FROM leaderboard_players').all(),
|
|
matches: db().prepare('SELECT * FROM leaderboard_matches').all(),
|
|
participation: db().prepare('SELECT * FROM leaderboard_match_players').all(),
|
|
beta: db().prepare('SELECT * FROM beta_test_evidence').all(),
|
|
lifecycle: db()
|
|
.prepare(
|
|
'SELECT active_since, inactive_since, game_started_at, game_ended_at FROM servers'
|
|
)
|
|
.all()
|
|
};
|
|
}
|
|
|
|
function streamedRequest(chunks: Uint8Array[], headers: Record<string, string> = {}) {
|
|
let index = 0;
|
|
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
|
|
if (index === chunks.length) controller.close();
|
|
else controller.enqueue(chunks[index++]);
|
|
});
|
|
const cancel = vi.fn();
|
|
const request = new Request('http://localhost/api/telemetry', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...headers },
|
|
body: new ReadableStream({ pull, cancel }, { highWaterMark: 0 }),
|
|
duplex: 'half'
|
|
} as RequestInit);
|
|
return { request, pull, cancel };
|
|
}
|
|
|
|
it.each(events)('preserves exact-limit player identities for %s', async (event) => {
|
|
const payload = gameEvent(event, 17, 1);
|
|
// Lengths use UTF-16 code units, while the request cap counts UTF-8 bytes.
|
|
const name = ` ${'🍒'.repeat(126)}é `;
|
|
const guid = `000${'9'.repeat(identityLimit - 3)}`;
|
|
expect(name.length).toBe(identityLimit);
|
|
payload.players[0] = { ...payload.players[0], name, guid };
|
|
|
|
const response = await post(payload);
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).telemetry.players).toEqual(payload.players);
|
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
expect(listTelemetry()[0].players).toEqual(payload.players);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare('SELECT name, guid FROM leaderboard_players').all()).toEqual([
|
|
{ name, guid }
|
|
]);
|
|
});
|
|
|
|
it('preserves an opaque Unicode GUID at the length limit', async () => {
|
|
const payload = gameEvent('game_start', 0, 1);
|
|
payload.players[0].guid = ` ${'識'.repeat(identityLimit - 2)} `;
|
|
const response = await post(payload);
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).telemetry.players).toEqual(payload.players);
|
|
});
|
|
|
|
it.each(events)('rejects oversized %s identities without changing game evidence', async (event) => {
|
|
expect((await post(gameEvent('heartbeat', 5))).status).toBe(200);
|
|
const before = await storedEvidence();
|
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
try {
|
|
for (const field of ['name', 'guid'] as const) {
|
|
for (const length of [identityLimit + 1, 20_000]) {
|
|
const payload = gameEvent(event, 17, 1);
|
|
payload.players[0] = { ...payload.players[0], [field]: 'x'.repeat(length) };
|
|
const response = await post(payload);
|
|
expect(response.status).toBe(400);
|
|
const result = await response.json();
|
|
expect(result).toMatchObject({ ok: false });
|
|
expect(result.error).toContain(`players[0].${field}`);
|
|
expect(result.error).toContain(String(identityLimit));
|
|
}
|
|
}
|
|
expect(await storedEvidence()).toEqual(before);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
|
|
it('accepts a telemetry body at the exact 1 MiB UTF-8 byte limit', async () => {
|
|
const payload = gameEvent('heartbeat', 17, 1);
|
|
const json = JSON.stringify({ ...payload, diagnostic: 'é🍒' });
|
|
const bytes = Buffer.from(json + ' '.repeat(JSON_BODY_LIMIT - Buffer.byteLength(json)));
|
|
expect(bytes.byteLength).toBe(1024 * 1024);
|
|
const { request } = streamedRequest([bytes.subarray(0, 100), bytes.subarray(100)], {
|
|
Authorization: `Bearer ${TEST_TELEMETRY_TOKEN}`,
|
|
'Content-Length': String(bytes.byteLength)
|
|
});
|
|
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
|
const response = await POST({ request });
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).telemetry).toMatchObject(payload);
|
|
});
|
|
|
|
it.each(['declared', 'absent', 'understated'] as const)(
|
|
'rejects body overflow with %s Content-Length before storing or broadcasting it',
|
|
async (length) => {
|
|
expect((await post(gameEvent('heartbeat', 5))).status).toBe(200);
|
|
const before = await storedEvidence();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const diagnostics = db().prepare('SELECT * FROM telemetry_requests').all();
|
|
const payload = JSON.stringify(gameEvent('game_end', 99));
|
|
const bytes = Buffer.from(
|
|
payload + ' '.repeat(JSON_BODY_LIMIT - Buffer.byteLength(payload))
|
|
);
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${TEST_TELEMETRY_TOKEN}`
|
|
};
|
|
if (length !== 'absent')
|
|
headers['Content-Length'] = length === 'declared' ? String(JSON_BODY_LIMIT + 1) : '1';
|
|
const { request, pull, cancel } = streamedRequest(
|
|
[bytes, Uint8Array.of(32), Buffer.from('unread')],
|
|
headers
|
|
);
|
|
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
try {
|
|
await expect(POST({ request })).rejects.toMatchObject({ status: 413 });
|
|
expect(pull).toHaveBeenCalledTimes(length === 'declared' ? 0 : 2);
|
|
expect(cancel).toHaveBeenCalledOnce();
|
|
expect(await storedEvidence()).toEqual(before);
|
|
expect(db().prepare('SELECT * FROM telemetry_requests').all()).toEqual(diagnostics);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
}
|
|
);
|
|
|
|
it.each([401, 503])('preserves authentication rejection %s before body limits', async (status) => {
|
|
if (status === 503) vi.stubEnv('EC_TELEMETRY_TOKEN', undefined);
|
|
const before = await storedEvidence();
|
|
const { request, pull } = streamedRequest([Buffer.alloc(JSON_BODY_LIMIT + 1)], {
|
|
'Content-Length': String(JSON_BODY_LIMIT + 1)
|
|
});
|
|
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
try {
|
|
expect((await POST({ request })).status).toBe(status);
|
|
expect(pull).not.toHaveBeenCalled();
|
|
expect(request.bodyUsed).toBe(false);
|
|
expect(await storedEvidence()).toEqual(before);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare('SELECT * FROM telemetry_requests').all()).toEqual([]);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|