287 lines
18 KiB
JavaScript
287 lines
18 KiB
JavaScript
import {after, before, test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {readFile, readdir} from 'node:fs/promises';
|
|
import {request as httpRequest} from 'node:http';
|
|
import {Miniflare, convertV4MiniflareOptions} from 'miniflare';
|
|
import worker from '../src/index.js';
|
|
|
|
const ADMIN = 'test-admin-credential-0123456789abcdef';
|
|
const READ = 'test-read-only-credential-0123456789abcdef';
|
|
const serverId = '11111111-1111-4111-8111-111111111111';
|
|
const otherServer = '22222222-2222-4222-8222-222222222222';
|
|
const playerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
|
let mf, db, token, otherToken;
|
|
const auth = {authorization: `Bearer ${ADMIN}`};
|
|
const readAuth = {authorization: `Bearer ${READ}`};
|
|
function event(overrides = {}) {
|
|
return {
|
|
schemaVersion: 1, eventId: crypto.randomUUID(), missionId: crypto.randomUUID(),
|
|
serverId, serverName: 'Test server', type: 'mission_finished', occurredAt: 1793360000,
|
|
elapsedSeconds: 120, partial: false, phase: 5, outcome: 'victory', previousMissionId: '',
|
|
previousOutcome: '', terrain: '{1234567890ABCDEF}Worlds/Test.ent', scenario: 'Escape',
|
|
gameVersion: '1.6', mods: [{id: '2528466DE220D91C', name: 'Co10 Escape Reintroduced', version: '0.0.125'}],
|
|
modsTruncated: false, playersTruncated: false, connectedPlayers: 1, peakPlayers: 1,
|
|
players: [{id: playerId, name: 'Player', authenticated: true, connected: true,
|
|
participated: true, escaped: true, connectedSeconds: 120, kills: 2, friendlyKills: 0,
|
|
deaths: 0, downed: 1, recoveries: 1}],
|
|
reason: 'Escape complete', preparationError: '', settings: {telemetryEnabled: 1, random: -1},
|
|
droppedEvents: 0, ...overrides,
|
|
};
|
|
}
|
|
async function call(path, options = {}) {
|
|
return mf.dispatchFetch(`https://telemetry.example${path}`, options);
|
|
}
|
|
async function post(path, body, headers = {}) {
|
|
return call(path, {method: 'POST', headers: {'content-type': 'application/json', ...headers}, body: JSON.stringify(body)});
|
|
}
|
|
const ingest = (e, credential = token) => post('/api/mission-events', {token: credential, event: e});
|
|
async function detail(e) {
|
|
const response = await call(`/api/missions/${e.serverId}/${e.missionId}`, {headers: readAuth});
|
|
assert.equal(response.status, 200); return (await response.json()).event;
|
|
}
|
|
before(async () => {
|
|
mf = new Miniflare(convertV4MiniflareOptions({host: '127.0.0.1', port: 0, workers: [{name: 'telemetry',
|
|
modules: ['index.js', 'validation.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
|
|
compatibilityDate: '2026-10-23', d1Databases: ['DB'],
|
|
ratelimits: {
|
|
REQUEST_LIMITER: {namespace_id: '1001', simple: {limit: 300, period: 60}},
|
|
ENROLLMENT_LIMITER: {namespace_id: '1002', simple: {limit: 10, period: 60}},
|
|
SERVER_LIMITER: {namespace_id: '1003', simple: {limit: 30, period: 60}},
|
|
},
|
|
bindings: {ADMIN_TOKEN: ADMIN, READ_TOKEN: READ}}]}));
|
|
db = await mf.getD1Database('DB');
|
|
const migrations = new URL('../migrations/', import.meta.url);
|
|
for (const name of (await readdir(migrations)).filter(n => n.endsWith('.sql')).sort()) {
|
|
const migration = await readFile(new URL(name, migrations), 'utf8');
|
|
// Blank-line-separated statements preserve the trigger's internal semicolons.
|
|
for (const sql of migration.split(/;\n\n/)) if (sql.trim()) await db.prepare(sql).run();
|
|
}
|
|
for (const sid of [serverId, otherServer]) {
|
|
const response = await post('/api/servers', {serverId: sid, name: 'Test'}, auth);
|
|
assert.equal(response.status, 201, await response.clone().text());
|
|
const body = await response.json();
|
|
assert.equal(body.token.length, 64);
|
|
if (sid === serverId) token = body.token; else otherToken = body.token;
|
|
}
|
|
});
|
|
after(async () => { await mf?.dispose(); });
|
|
|
|
test('credentials bind writes to a registered server; read-only credentials cannot administer', async () => {
|
|
assert.equal((await ingest(event(), 'x'.repeat(64))).status, 401);
|
|
assert.equal((await ingest(event({serverId: otherServer}))).status, 401);
|
|
assert.equal((await call('/api/stats')).status, 401);
|
|
assert.equal((await post('/api/servers', {serverId: crypto.randomUUID(), name: 'No'}, readAuth)).status, 401);
|
|
assert.equal((await call('/api/stats', {headers: readAuth})).status, 200);
|
|
const stored = await db.prepare('SELECT token_hash FROM servers WHERE server_id = ?').bind(serverId).first();
|
|
assert.notEqual(stored.token_hash, token);
|
|
assert.equal((await post('/api/servers', {serverId, name: 'Duplicate'}, auth)).status, 409);
|
|
});
|
|
|
|
test('concurrent retries store once; reordered JSON is equivalent; conflicting content is rejected', async () => {
|
|
const e = event();
|
|
const responses = await Promise.all(Array.from({length: 6}, () => ingest(e)));
|
|
assert.deepEqual(responses.map(r => r.status).sort(), [200, 200, 200, 200, 200, 201]);
|
|
const reordered = Object.fromEntries(Object.entries(e).reverse());
|
|
assert.equal((await ingest(reordered)).status, 200);
|
|
assert.equal((await ingest({...e, elapsedSeconds: 999})).status, 409);
|
|
const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE event_id = ?').bind(e.eventId).first();
|
|
assert.equal(count.n, 1);
|
|
assert.equal((await detail(e)).elapsedSeconds, 120);
|
|
const row = await db.prepare('SELECT payload FROM events WHERE event_id = ?').bind(e.eventId).first();
|
|
assert.ok(!row.payload.includes(token)); assert.ok(!row.payload.includes('"token"'));
|
|
assert.equal((await ingest({...e, serverId: otherServer}, otherToken)).status, 201);
|
|
});
|
|
|
|
test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => {
|
|
const e = event({players: []});
|
|
const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100};
|
|
assert.equal((await ingest(heartbeat)).status, 201);
|
|
assert.equal((await ingest({...heartbeat, eventId: crypto.randomUUID(), elapsedSeconds: 50, occurredAt: e.occurredAt + 500})).status, 201);
|
|
assert.equal((await detail(e)).elapsedSeconds, 100);
|
|
assert.equal((await ingest({...e, eventId: crypto.randomUUID(), occurredAt: e.occurredAt - 100})).status, 201);
|
|
for (const type of ['mission_started', 'mission_restarted', 'heartbeat', 'telemetry_resumed']) {
|
|
assert.equal((await ingest({...heartbeat, type, eventId: crypto.randomUUID(), elapsedSeconds: 1000})).status, 201);
|
|
}
|
|
assert.equal((await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6})).status, 201);
|
|
assert.equal((await detail(e)).outcome, 'victory');
|
|
const rows = await db.prepare('SELECT COUNT(*) AS n FROM missions WHERE server_id = ? AND mission_id = ?').bind(serverId, e.missionId).first();
|
|
assert.equal(rows.n, 1);
|
|
const log = await call(`/api/missions/${serverId}/${e.missionId}/events?limit=2`, {headers: readAuth});
|
|
const data = await log.json(); assert.equal(data.events.length, 2); assert.ok(data.nextBefore);
|
|
});
|
|
|
|
test('rankings use actual authenticated participant escapes and preserve absent legacy counters', async () => {
|
|
const isolated = crypto.randomUUID();
|
|
const credentials = await (await post('/api/servers', {serverId: isolated, name: 'Rankings'}, auth)).json();
|
|
const e = event({serverId: isolated});
|
|
const legacy = {...e.players[0], escaped: false};
|
|
for (const key of ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries']) delete legacy[key];
|
|
const spectator = {...legacy, id: crypto.randomUUID(), participated: false};
|
|
const anonymous = {...legacy, id: `${e.missionId}:connection:1`, authenticated: false};
|
|
assert.equal((await ingest({...e, players: [legacy, spectator, anonymous]}, credentials.token)).status, 201);
|
|
for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) {
|
|
assert.equal((await ingest(event({serverId: isolated, outcome, phase: 0, players: [legacy]}), credentials.token)).status, 201);
|
|
}
|
|
const url = `/api/players?serverId=${isolated}`;
|
|
let result = await (await call(url, {headers: readAuth})).json();
|
|
assert.equal(result.players.length, 1);
|
|
assert.equal(result.players[0].attempts, 1); assert.equal(result.players[0].escapes, 0);
|
|
assert.equal(result.players[0].kills, null); assert.equal(result.players[0].missionsWithKills, 0);
|
|
assert.equal((await ingest(event({serverId: isolated}), credentials.token)).status, 201);
|
|
result = await (await call(url, {headers: readAuth})).json();
|
|
assert.equal(result.players[0].attempts, 2); assert.equal(result.players[0].escapes, 1);
|
|
assert.equal(result.players[0].kills, 2); assert.equal(result.players[0].missionsWithKills, 1);
|
|
const totals = await (await call(`/api/stats?serverId=${isolated}`, {headers: readAuth})).json();
|
|
assert.deepEqual(Object.fromEntries(totals.outcomes.map(r => [r.outcome, r.missions])),
|
|
{victory: 2, preparation_failed: 1, restarted_empty: 1, interrupted: 1});
|
|
});
|
|
|
|
test('real chunked HTTP request is accepted without Content-Length', async () => {
|
|
const origin = await mf.ready;
|
|
const body = JSON.stringify({token, event: event()});
|
|
const response = await new Promise((resolve, reject) => {
|
|
const req = httpRequest(new URL('/api/mission-events', origin), {method: 'POST', headers: {'content-type': 'application/json'}}, res => {
|
|
let text = ''; res.on('data', chunk => { text += chunk; });
|
|
res.on('end', () => resolve({status: res.statusCode, body: JSON.parse(text)}));
|
|
});
|
|
req.on('error', reject); req.write(body.slice(0, 80)); req.end(body.slice(80));
|
|
});
|
|
assert.equal(response.status, 201); assert.equal(response.body.accepted, true);
|
|
});
|
|
|
|
test('schema validation and streaming limits reject malformed input without durable rows', async () => {
|
|
const e = event();
|
|
for (const malformed of [
|
|
{...e, schemaVersion: 2}, {...e, phase: 4}, {...e, players: [...e.players, ...e.players]},
|
|
{...e, players: [{...e.players[0], kills: -1}]}, {...e, extraSecret: token},
|
|
{...e, players: [{...e.players[0], participated: false}]},
|
|
{...e, players: Array.from({length: 129}, () => e.players[0])},
|
|
]) assert.equal((await ingest(malformed)).status, 400);
|
|
assert.equal((await call('/api/mission-events', {method: 'POST', body: '{}'})).status, 415);
|
|
assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: '{'})).status, 400);
|
|
const stream = new ReadableStream({start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(' '.repeat(270337))); controller.close();
|
|
}});
|
|
assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: stream, duplex: 'half'})).status, 413);
|
|
assert.equal((await call('/api/missions?limit=1000000', {headers: readAuth})).status, 400);
|
|
assert.equal((await call('/api/missions?serverId=invalid', {headers: readAuth})).status, 400);
|
|
});
|
|
|
|
test('failed projection rolls back the event and returns non-2xx; retry succeeds after repair', async () => {
|
|
const e = event();
|
|
await db.prepare(`CREATE TRIGGER simulate_storage_failure BEFORE INSERT ON missions
|
|
WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run();
|
|
assert.equal((await ingest(e)).status, 503);
|
|
assert.equal(await db.prepare('SELECT event_id FROM events WHERE event_id = ?').bind(e.eventId).first(), null);
|
|
await db.prepare('DROP TRIGGER simulate_storage_failure').run();
|
|
assert.equal((await ingest(e)).status, 201);
|
|
const result = await worker.fetch(new Request('https://example/api/stats', {headers: auth}), {});
|
|
assert.equal(result.status, 503); // Missing admin configuration fails closed.
|
|
});
|
|
|
|
test('server revocation retains history; rotation invalidates old credential and restores ingestion', async () => {
|
|
const sid = crypto.randomUUID();
|
|
const registered = await (await post('/api/servers', {serverId: sid, name: 'Rotation'}, auth)).json();
|
|
const e = event({serverId: sid});
|
|
assert.equal((await ingest(e, registered.token)).status, 201);
|
|
assert.equal((await call(`/api/servers/${sid}`, {method: 'DELETE', headers: auth})).status, 200);
|
|
assert.equal((await ingest(e, registered.token)).status, 401);
|
|
assert.equal((await detail(e)).outcome, 'victory');
|
|
const rotated = await (await call(`/api/servers/${sid}/token`, {method: 'POST', headers: auth})).json();
|
|
assert.equal((await ingest(e, registered.token)).status, 401);
|
|
assert.equal((await ingest(e, rotated.token)).status, 200);
|
|
});
|
|
|
|
test('unmanaged servers enroll on first valid event and keep their own private write credential', async () => {
|
|
const e = event({serverId: crypto.randomUUID()}), credential = crypto.randomUUID() + crypto.randomUUID();
|
|
assert.equal((await ingest({...e, schemaVersion: 99}, credential)).status, 400);
|
|
assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null);
|
|
assert.equal((await ingest(e, credential)).status, 201);
|
|
const row = await db.prepare('SELECT registration, token_hash FROM servers WHERE server_id = ?').bind(e.serverId).first();
|
|
assert.equal(row.registration, 'automatic'); assert.notEqual(row.token_hash, credential);
|
|
assert.equal((await ingest(e, credential)).status, 200); // lost acknowledgement / restart
|
|
assert.equal((await ingest({...e, eventId: crypto.randomUUID()}, 'x'.repeat(64))).status, 401);
|
|
await call(`/api/servers/${e.serverId}`, {method: 'DELETE', headers: auth});
|
|
assert.equal((await ingest(e, credential)).status, 401); // no automatic reactivation
|
|
assert.equal((await call('/api/servers', {headers: {authorization: `Bearer ${credential}`}})).status, 401);
|
|
});
|
|
|
|
test('concurrent automatic claims never let two credentials own the same server', async () => {
|
|
const e = event({serverId: crypto.randomUUID()}), a = 'a'.repeat(64), b = 'b'.repeat(64);
|
|
const replies = await Promise.all([ingest(e, a), ingest({...e, eventId: crypto.randomUUID()}, b)]);
|
|
assert.deepEqual(replies.map(r => r.status).sort(), [201, 401]);
|
|
const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE server_id = ?').bind(e.serverId).first();
|
|
assert.equal(count.n, 1);
|
|
});
|
|
|
|
test('automatic claim and event roll back together when projection fails', async () => {
|
|
const e = event({serverId: crypto.randomUUID()}), credential = 'r'.repeat(64);
|
|
await db.prepare(`CREATE TRIGGER fail_new_server BEFORE INSERT ON missions
|
|
WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run();
|
|
assert.equal((await ingest(e, credential)).status, 503);
|
|
assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null);
|
|
await db.prepare('DROP TRIGGER fail_new_server').run();
|
|
assert.equal((await ingest(e, credential)).status, 201);
|
|
});
|
|
|
|
test('public rate limits reject excess traffic without storing more events', async () => {
|
|
const e = event({serverId: crypto.randomUUID()}), credential = 'q'.repeat(64);
|
|
assert.equal((await ingest(e, credential)).status, 201);
|
|
let rejected = false;
|
|
for (let i = 0; i < 35; i++) {
|
|
if ((await ingest(e, credential)).status === 429) { rejected = true; break; }
|
|
}
|
|
assert.ok(rejected, 'per-server binding must enforce its quota');
|
|
const input = () => new Request('https://example/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'},
|
|
body: JSON.stringify({token: credential, event: event({serverId: crypto.randomUUID()})})});
|
|
const allowed = {limit: async () => ({success: true})};
|
|
const denied = {limit: async () => ({success: false})};
|
|
assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: allowed, ENROLLMENT_LIMITER: denied})).status, 429);
|
|
assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: denied})).status, 429);
|
|
assert.equal((await worker.fetch(input(), {DB: db})).status, 503);
|
|
});
|
|
|
|
test('public map summary is usable in a browser and empty maps have zero results, not an invented win rate', async () => {
|
|
const terrain = '{ABCD000000000000}Worlds/NoReports.ent';
|
|
for (const path of ['/', '/api/summary', '/api/mission-events']) {
|
|
const response = await call(path + '?terrain=' + encodeURIComponent(terrain));
|
|
assert.equal(response.status, 200);
|
|
const data = await response.json();
|
|
assert.deepEqual(data.allServers, {completed: 0, wins: 0, losses: 0, partialMissions: 0, winRate: null});
|
|
assert.equal(data.server, null);
|
|
}
|
|
assert.equal((await call('/api/players')).status, 401);
|
|
assert.equal((await call('/api/missions')).status, 401);
|
|
});
|
|
|
|
test('map records count victories and defeats across servers, excluding other maps and non-gameplay endings', async () => {
|
|
const terrain = '{ABCD123456789012}Worlds/MapRecord.ent';
|
|
const e = event({terrain, serverId: otherServer});
|
|
const first = await (await ingest(e, otherToken)).json();
|
|
assert.equal(first.community.eventId, e.eventId);
|
|
assert.equal(first.community.terrain, terrain);
|
|
assert.deepEqual(first.community.allServers, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100});
|
|
const defeat = event({terrain, serverId, outcome: 'defeat', phase: 6, players: [], partial: true});
|
|
assert.equal((await ingest(defeat)).status, 201);
|
|
for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) {
|
|
assert.equal((await ingest(event({terrain, serverId: otherServer, outcome, phase: 0, players: []}), otherToken)).status, 201);
|
|
}
|
|
assert.equal((await ingest(event({terrain: 'Different-map', serverId: otherServer}), otherToken)).status, 201);
|
|
// Case differences in resource spelling do not split the same loaded map.
|
|
const params = new URLSearchParams({terrain: terrain.toLowerCase(), serverId: otherServer});
|
|
const response = await call('/api/summary?' + params);
|
|
const summary = await response.json();
|
|
assert.deepEqual(summary.allServers, {completed: 2, wins: 1, losses: 1, partialMissions: 1, winRate: 50});
|
|
assert.deepEqual(summary.server, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100});
|
|
const retry = await (await ingest(e, otherToken)).json();
|
|
assert.equal(retry.duplicate, true); assert.equal(retry.community.allServers.completed, 2);
|
|
// A conflicting second terminal event must not claim its result was included.
|
|
const later = await (await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6, players: []}, otherToken)).json();
|
|
assert.equal(later.community, undefined);
|
|
const text = JSON.stringify(summary);
|
|
for (const sensitive of [playerId, token, otherToken, 'Player', 'Test server', e.missionId]) assert.ok(!text.includes(sensitive));
|
|
const maps = await (await call('/')).json();
|
|
const row = maps.maps.find(m => m.terrain === terrain.toLowerCase());
|
|
assert.deepEqual(row, {terrain: terrain.toLowerCase(), completed: 2, wins: 1, losses: 1});
|
|
});
|