import assert from 'node:assert/strict'; import test from 'node:test'; import { once } from 'node:events'; import { randomUUID } from 'node:crypto'; import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join as joinPath } from 'node:path'; import { gzipSync } from 'node:zlib'; import { io, type Socket } from 'socket.io-client'; import { createApp, type AppOptions } from '../server/app.ts'; import { MemoryVisitStore, type VisitRecord } from '../server/visits.ts'; import { MULTIPLAYER } from '../src/constants/multiplayer.ts'; import type { JoinRequest, JoinResult, PlayerState, RoomState } from '../src/multiplayer/protocol.ts'; const request = (): JoinRequest => ({ locationId: MULTIPLAYER.locationId, roomId: MULTIPLAYER.roomId, visitorId: randomUUID(), nickname: 'Visitor' }); const pause = (ms = 30) => new Promise(resolve => setTimeout(resolve, ms)); async function until(check: () => boolean) { for (let i = 0; i < 100; i++) { if (check()) return; await pause(10); } assert.fail('condition timed out'); } async function fixture(store = new MemoryVisitStore(), options: Partial = {}) { const app = createApp({ visitStore: store, weather: false, onError: () => {}, ...options }); app.httpServer.listen(0, '127.0.0.1'); await once(app.httpServer, 'listening'); const url = `http://127.0.0.1:${(app.httpServer.address() as { port: number }).port}`; const clients: Socket[] = []; return { app, store, url, async connect() { const socket = io(url, { transports: ['websocket'], forceNew: true, reconnection: false }); clients.push(socket); await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('connect_error', reject); }); return socket; }, async close() { clients.forEach(socket => socket.disconnect()); await app.close(); } }; } const join = (socket: Socket, value = request()): Promise => socket.timeout(3000).emitWithAck('join', value); test('failed admission replies before blocked cleanup and releases its reservation', async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); class OfflineStore extends MemoryVisitStore { override async insert() { throw Error('offline'); } override async finish() { await gate; } } const f = await fixture(new OfflineStore()); try { const socket = await f.connect(); const result = await socket.timeout(500).emitWithAck('join', request()); assert.deepEqual(result, { ok: false, code: 'STORAGE_UNAVAILABLE' }); assert.equal(f.app.room.reserved, 0); } finally { release(); await f.close(); } }); test('lobby lists only the available summit channel and reports occupancy without joining', async () => { const f = await fixture(); try { const response = await fetch(`${f.url}/api/lobby`); assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store'); const lobby = await response.json(); assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'gwanaksan').enabled, true); assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'hajodae').enabled, false); assert.equal(lobby.servers.find((s: { id: string }) => s.id === 'gisamun').enabled, false); assert.deepEqual(lobby.channel, { id: MULTIPLAYER.roomId, name: '채널 1', capacity: 100, players: 0, available: true }); assert.equal(f.store.records.size, 0); await join(await f.connect()); assert.equal((await (await fetch(`${f.url}/api/lobby`)).json()).channel.players, 1); } finally { await f.close(); } }); function player(result: JoinResult): PlayerState { assert.equal(result.ok, true); if (!result.ok) throw Error(); return result.state.players.find(p => p.id === result.playerId)!; } test('100 real simultaneous admissions reserve capacity; 101 rejected and released slot reusable', async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); class SlowStore extends MemoryVisitStore { override async insert(record: VisitRecord) { await gate; await super.insert(record); } } const f = await fixture(new SlowStore()); try { const clients = await Promise.all(Array.from({ length: 101 }, () => f.connect())); const admissions = clients.map(c => join(c)); await until(() => f.app.room.reserved === 100); assert.equal(f.app.room.size, 0); release(); const results = await Promise.all(admissions); assert.equal(results.filter(r => r.ok).length, 100); assert.deepEqual(results.find(r => !r.ok), { ok: false, code: 'ROOM_FULL' }); const index = results.findIndex(r => r.ok); const duplicate = await join(clients[index]); assert.equal(player(duplicate).id, player(results[index]).id); assert.equal(f.store.records.size, 100); clients[index].disconnect(); await until(() => f.app.room.size === 99); assert.equal((await join(clients[results.findIndex(r => !r.ok)])).ok, true); } finally { release(); await f.close(); } }); test('pending admission is reserved, duplicate pending rejected, disconnect during insert closes once', async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); class SlowStore extends MemoryVisitStore { override async insert(record: VisitRecord) { await gate; await super.insert(record); } } const f = await fixture(new SlowStore()); try { const socket = await f.connect(); socket.emit('join', request(), () => {}); await until(() => f.app.room.reserved === 1); assert.deepEqual(await join(socket), { ok: false, code: 'JOIN_PENDING' }); socket.disconnect(); await until(() => f.app.io.sockets.sockets.size === 0); assert.equal(f.app.room.reserved, 1); release(); await until(() => f.store.records.size === 1 && [...f.store.records.values()][0].endReason !== null); assert.equal(f.app.room.reserved, 0); assert.equal(f.app.room.size, 0); assert.equal([...f.store.records.values()][0].endReason, 'disconnect'); } finally { release(); await f.close(); } }); test('storage failure denies admission and frees reservation; invalid joins never persist', async () => { class BrokenStore extends MemoryVisitStore { override async insert() { throw Error('offline'); } } const f = await fixture(new BrokenStore()); try { const socket = await f.connect(); assert.deepEqual(await join(socket, { ...request(), locationId: 'coast' }), { ok: false, code: 'INVALID_JOIN' }); assert.deepEqual(await join(socket, { ...request(), visitorId: 'spoof' }), { ok: false, code: 'INVALID_JOIN' }); assert.deepEqual(await join(socket), { ok: false, code: 'STORAGE_UNAVAILABLE' }); assert.equal(f.app.room.reserved, 0); assert.equal(f.app.room.size, 0); } finally { await f.close(); } }); test('validated gear reaches late joiner; invalid movement corrects; reset preserves gear and reconnect clears it', async () => { const f = await fixture(); try { const a = await f.connect(); const initial = player(await join(a)); const gear = { umbrella: true, raincoat: true, hood: true }; a.emit('player:update', { ...initial, equipment: gear, id: 'spoof' }); await until(() => f.app.room.snapshot().players[0]?.equipment.hood === true); const b = await f.connect(); const second = await join(b); assert.ok(second.ok); assert.deepEqual(second.state.players.find(p => p.id === initial.id)?.equipment, gear); assert.ok(second.state.players.every(p => p.id !== 'spoof')); for (const invalid of [{ x: 1e9 }, { yaw: 100 }, { equipment: { umbrella: false, raincoat: false, hood: true } }, { emote: 'hack' }]) { await pause(110); const correction = new Promise(resolve => a.once('player:correction', resolve)); a.emit('player:update', { ...initial, ...invalid }); assert.equal((await correction).x, initial.x); } const reset: PlayerState = await a.timeout(3000).emitWithAck('player:reset'); assert.deepEqual(reset.equipment, gear); a.disconnect(); await until(() => f.app.room.size === 1); const c = await f.connect(); const reconnected = player(await join(c)); assert.notEqual(reconnected.id, initial.id); assert.equal(reconnected.equipment.hood, false); const time = await c.timeout(3000).emitWithAck('clock:sync'); assert.ok(Math.abs(time - Date.now()) < 1000); assert.deepEqual(await (await fetch(f.url + '/health')).json(), { ok: true }); } finally { await f.close(); } }); test('server tick expires a wave even when its client sends no idle update', async () => { const f = await fixture(); try { const a = await f.connect(); const p = player(await join(a)); a.emit('player:update', { ...p, emote: 'wave' }); await until(() => f.app.room.snapshot().players[0].emote === 'wave'); await pause(3200); const ended = f.app.room.snapshot().players[0]; assert.equal(ended.emote, 'idle'); assert.ok(ended.updatedAt > p.updatedAt); } finally { await f.close(); } }); test('stationary room sends no 100ms snapshots, changed snapshots have increasing sequence', async () => { const f = await fixture(); try { const a = await f.connect(); const p = player(await join(a)); await pause(150); const states: RoomState[] = []; a.on('room:state', s => states.push(s)); await pause(250); assert.equal(states.length, 0); a.emit('player:update', { ...p, equipment: { umbrella: true, raincoat: false, hood: false } }); await until(() => states.length === 1); a.emit('player:reset', () => {}); await until(() => states.length === 2); assert.ok(states[1].sequence > states[0].sequence); } finally { await f.close(); } }); test('heartbeat and disconnect writes serialize, shutdown closes all visits and store', async () => { let release!: () => void, heartbeatStarted = false, closed = false; const gate = new Promise(resolve => { release = resolve; }); const writes: string[] = []; class SlowHeartbeat extends MemoryVisitStore { override async heartbeat(ids: string[], at: Date) { heartbeatStarted = true; await gate; writes.push('heartbeat'); await super.heartbeat(ids, at); } override async finish(id: string, at: Date, reason: 'disconnect' | 'shutdown' | 'interrupted') { writes.push(reason); await super.finish(id, at, reason); } override async close() { closed = true; } } const f = await fixture(new SlowHeartbeat()); try { const a = await f.connect(); await join(a); const b = await f.connect(); await join(b); const beat = f.app.room.heartbeat(); await until(() => heartbeatStarted); a.disconnect(); await until(() => f.app.room.size === 1); assert.deepEqual(writes, []); release(); await beat; await f.app.close(); assert.deepEqual(writes, ['heartbeat', 'disconnect', 'shutdown']); assert.equal(closed, true); assert.ok([...f.store.records.values()].every(record => record.leftAt !== null)); } finally { release(); await f.close(); } }); test('heartbeat failure fails closed for subsequent admission and health', async () => { class BrokenHeartbeat extends MemoryVisitStore { override async heartbeat() { throw Error('write failed'); } } const f = await fixture(new BrokenHeartbeat()); try { const a = await f.connect(); await join(a); await f.app.room.heartbeat(); const b = await f.connect(); assert.deepEqual(await join(b), { ok: false, code: 'STORAGE_UNAVAILABLE' }); const health = await fetch(f.url + '/health'); assert.equal(health.status, 503); assert.deepEqual(await health.json(), { ok: false }); } finally { await f.close(); } }); test('movement spends a bounded shared token budget, rejects unsafe path and malformed updates', async () => { const f = await fixture(); try { const a = await f.connect(); let p = player(await join(a)); const start = { ...p }; // Repeated 2m moves at 10Hz must not get a fresh per-packet allowance. a.emit('player:update', { ...p, x: p.x + 1.5, moving: true }); await until(() => f.app.room.snapshot().players[0].x !== p.x); p = f.app.room.snapshot().players[0]; await pause(110); const corrected = new Promise(resolve => a.once('player:correction', resolve)); a.emit('player:update', { ...p, x: p.x + 1.5, moving: true }); assert.equal((await corrected).x, p.x); assert.ok(Math.abs(p.x - start.x) <= 2); for (const update of [null, { ...p, x: null }, { ...p, moving: 1 }, { ...p, equipment: { umbrella: 'true', raincoat: false, hood: false } }]) { await pause(110); const correction = new Promise(resolve => a.once('player:correction', resolve)); a.emit('player:update', update); assert.equal((await correction).id, p.id); } } finally { await f.close(); } }); test('weather snapshot is delivered to late joiner and server updates are shared', async () => { const f = await fixture(); try { const state = { status: 'ready' as const, weather: { cloudCover: 80, visibility: 1000, rain: 3, snowfall: 0 } }; f.app.room.setWeather(state); const a = await f.connect(); const received = new Promise(resolve => a.once('weather:state', resolve)); await join(a); assert.deepEqual(await received, state); const changed = new Promise(resolve => a.once('weather:state', resolve)); f.app.room.setWeather({ status: 'error', weather: null }); assert.deepEqual(await changed, { status: 'error', weather: null }); } finally { await f.close(); } }); test('origin checks apply to WebSocket and oversized payload disconnects', async () => { const f = await fixture(); try { const rejected = io(f.url, { transports: ['websocket'], forceNew: true, reconnection: false, extraHeaders: { Origin: 'https://untrusted.example' } }); try { await new Promise((resolve, reject) => { rejected.once('connect_error', () => resolve()); rejected.once('connect', () => reject(Error('origin accepted'))); }); } finally { rejected.disconnect(); } const a = await f.connect(); await join(a); const disconnected = new Promise(resolve => a.once('disconnect', resolve)); a.emit('player:update', { data: 'x'.repeat(5000) }); await disconnected; await until(() => f.app.room.size === 0); } finally { await f.close(); } }); test('static dist serving supports gzip and HEAD and rejects traversal and hidden files', async () => { const directory = await mkdtemp(joinPath(tmpdir(), 'sunrise-server-')); await writeFile(joinPath(directory, 'index.html'), '

Sunrise

'); await writeFile(joinPath(directory, 'asset.bin'), 'asset payload'); await writeFile(joinPath(directory, 'asset.bin.gz'), gzipSync('asset payload')); await writeFile(joinPath(directory, '.env'), 'secret'); const f = await fixture(new MemoryVisitStore(), { distDirectory: directory }); try { assert.match(await (await fetch(f.url)).text(), /Sunrise/); const gzip = await fetch(f.url + '/asset.bin'); assert.equal(gzip.headers.get('content-encoding'), 'gzip'); assert.equal(await gzip.text(), 'asset payload'); const head = await fetch(f.url + '/asset.bin', { method: 'HEAD' }); assert.equal(await head.text(), ''); for (const path of ['/.env', '/%2e%2e%5cpackage.json', '/%00', '/C%3a/windows']) assert.equal((await fetch(f.url + path)).status, 404); const identity = await fetch(f.url + '/asset.bin', { headers: { 'Accept-Encoding': 'gzip;q=0' } }); assert.equal(identity.headers.get('content-encoding'), null); } finally { await f.close(); await rm(directory, { recursive: true, force: true }); } });