/** * The HTTP layer, driven end to end over a real socket. `startServer` binds port 0 on a temp data * directory; nothing here reads the built site, so `distDir` is a directory with nothing in it. * * Added in v0.8.4, when four faults in `http.ts` turned out to be uncovered because no test had ever * stood the server up: a leaver's token surviving the leave, an unbounded body, a torn save under * concurrent moves, and a crash on an error after the SSE head was sent. */ import { describe, it, after, before } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Server } from 'node:http'; import type { GameConfig } from '../../src/engine/state.ts'; import { startServer } from '../../src/server/http.ts'; const SECRET = 'test-secret'; const config: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, houseRules: { startingOffice: 'whistlePost' }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, }; let server: Server; let base = ''; let dataDir = ''; before(async () => { dataDir = await mkdtemp(join(tmpdir(), 'station-master-http-')); server = startServer({ port: 0, bindAddress: '127.0.0.1', joinSecret: SECRET, distDir: dataDir, dataDir, engineVersion: 'test', initialGames: new Map(), initialLobbies: new Map(), initialSessions: new Map(), }); await new Promise((resolve) => server.once('listening', resolve)); const addr = server.address(); if (!addr || typeof addr === 'string') throw new Error('no port'); base = `http://127.0.0.1:${addr.port}`; }); after(async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); // A write queued behind the last move may still be landing; retry rather than race it. await rm(dataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); }); const post = async (path: string, body: unknown): Promise<{ status: number; json: Record }> => { const res = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return { status: res.status, json: (await res.json()) as Record }; }; const get = async (path: string): Promise => (await fetch(base + path)).status; /** The first SSE message on a stream, then the stream is dropped. */ async function firstPush(path: string): Promise> { const res = await fetch(base + path); assert.equal(res.status, 200, `${path} answered ${res.status}`); const reader = res.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; for (;;) { const { value, done } = await reader.read(); if (done) throw new Error('stream ended before a push'); buffer += decoder.decode(value, { stream: true }); const m = /data: (.*)\n\n/.exec(buffer); if (m) { await reader.cancel(); return JSON.parse(m[1]!) as Record; } } } type Seat = { token: string; player: number; gameId: string; gameCode: string }; async function table(): Promise<{ host: Seat; guest: Seat }> { const created = await post('/api/lobby/create', { secret: SECRET, config, displayName: 'Host', players: 2 }); assert.equal(created.status, 200, JSON.stringify(created.json)); const host = created.json as unknown as Seat; const joined = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Guest' }); assert.equal(joined.status, 200, JSON.stringify(joined.json)); return { host, guest: joined.json as unknown as Seat }; } describe('the HTTP layer (v0.8.4)', () => { it('revokes the token of a player who leaves, so it cannot play the seat the next arrival takes', async () => { const { host, guest } = await table(); const left = await post('/api/lobby/leave', { token: guest.token }); assert.equal(left.status, 200); // The leaver's token is dead at once — for the lobby and for the game that follows. assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404, 'a leaver can still watch the lobby'); const again = await post('/api/lobby/join', { secret: SECRET, gameCode: host.gameCode, displayName: 'Newcomer' }); assert.equal(again.status, 200); assert.equal(again.json['player'], guest.player, 'the vacated chair was not the one re-offered'); const started = await post('/api/lobby/start', { token: host.token }); assert.equal(started.status, 200, JSON.stringify(started.json)); assert.equal(await get(`/api/session?token=${guest.token}`), 404, 'the leaver still holds a seat in the running game'); assert.equal(await get(`/api/stream?token=${guest.token}`), 404, "the leaver can read the newcomer's stream"); const move = await post(`/api/intent?token=${guest.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } }); assert.equal(move.status, 404, 'the leaver can move for the newcomer'); // And the newcomer's own token works. assert.equal(await get(`/api/session?token=${again.json['token'] as string}`), 200); // On disk too, so a restart does not hand the seat back. const onDisk = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'sessions.json'), 'utf8')) as { token: string }[]; assert.ok(!onDisk.some((s) => s.token === guest.token), 'the revoked token is still in sessions.json'); }); it('lets the host remove a player, revoking that token the same way', async () => { const { host, guest } = await table(); const removed = await post('/api/lobby/leave', { token: host.token, seat: guest.player }); assert.equal(removed.status, 200, JSON.stringify(removed.json)); assert.equal(await get(`/api/lobby/stream?token=${guest.token}`), 404); assert.equal(await get(`/api/lobby/stream?token=${host.token}`), 200, 'the host lost their own seat'); }); it('refuses an oversized body before reading it, and a malformed one with 400', async () => { const big = await fetch(base + '/api/claim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: 'x'.repeat(200_000) }), }); assert.equal(big.status, 413); const bad = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{not json' }); assert.equal(bad.status, 400); const notObject = await fetch(base + '/api/lobby/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'null' }); assert.equal(notObject.status, 400); }); it('tells a connecting seat where its intent count stands', async () => { const { host, guest } = await table(); assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200); const hostPush = await firstPush(`/api/stream?token=${host.token}`); const actor = hostPush['menu'] !== null ? host : guest; assert.equal(hostPush['lastSeq'], 0); const move = await post(`/api/intent?token=${actor.token}`, { seq: 1, intent: { type: 'localOps.choose', option: 'draw' } }); assert.deepEqual(move.json, { ok: true }); const reconnect = await firstPush(`/api/stream?token=${actor.token}`); assert.equal(reconnect['lastSeq'], 1, 'the reconnect push does not carry the count'); }); it('applies a burst of concurrent moves one at a time and leaves the save readable', async () => { const { host, guest } = await table(); assert.equal((await post('/api/lobby/start', { token: host.token })).status, 200); const hostPush = await firstPush(`/api/stream?token=${host.token}`); const actor = hostPush['menu'] !== null ? host : guest; // Three moves that are legal only in this order, fired together. const intents = [ { type: 'localOps.choose', option: 'draw' }, { type: 'draw.fromHomeOffice' }, { type: 'draw.end' }, ]; const results = await Promise.all(intents.map((intent, i) => post(`/api/intent?token=${actor.token}`, { seq: i + 1, intent }))); assert.ok(results.every((r) => r.status === 200), 'a concurrent move was answered with an error'); const save = JSON.parse(await readFile(join(dataDir, 'games', host.gameId, 'game.json'), 'utf8')) as { history: unknown[] }; assert.ok(save.history.length >= 1, 'no move reached the save'); assert.equal(save.history.length, results.filter((r) => r.json['ok'] === true).length, 'the save and the answers disagree'); }); });