import { test } from 'node:test'; import assert from 'node:assert/strict'; import net from 'node:net'; import { io as connect } from 'socket.io-client'; import { loadConfig } from '../src/config.js'; import { createApp } from '../src/app.js'; import { DemoServerAdapter } from '../src/servers/demoAdapter.js'; import { applyLogLines, initialLiveState } from '../src/liveScore.js'; import { RconClient } from '../src/servers/rcon.js'; import { createRateLimiter, safeErrorMessage } from '../src/security.js'; // Absicherungen aus der Prüfung: jede Stelle mit dem Fehler, der vorher möglich war. const quiet = { log() {}, warn() {}, error() {} }; async function start(overrides = {}, adapter) { const config = loadConfig({ databaseFile: ':memory:', serverAdapter: 'demo', createMatchPassword: '', demo: { startSeconds: 0, failFirstConfigure: false }, ...overrides, }); const app = createApp(config, { logger: quiet, adapter }); await new Promise((resolve) => app.server.listen(0, '127.0.0.1', resolve)); const port = app.server.address().port; const call = async (method, path, { token, body } = {}) => { const res = await fetch(`http://127.0.0.1:${port}/api${path}`, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json() }; }; return { app, port, call, close: () => app.close() }; } const newMatch = (call) => call('POST', '/matches', { body: { teamA: 'Alpha', teamB: 'Bravo', mode: 'wingman', format: 'bo1', vetoStart: 'A' } }); test('Kaputte Socket-Nachrichten beenden das Backend nicht', async () => { const { port, call, close } = await start(); const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false }); try { await new Promise((resolve, reject) => { socket.on('connect', resolve); socket.on('connect_error', reject); }); for (const payload of [null, 5, 'x', [], { matchId: { $ne: 1 } }]) { socket.emit('stream:subscribe', payload); socket.emit('match:subscribe', payload); socket.emit('overview:subscribe', payload); } await new Promise((r) => setTimeout(r, 200)); assert.equal((await call('GET', '/config')).status, 200, 'Backend antwortet noch'); } finally { socket.close(); await close(); } }); test('Zu viele Socket-Abfragen werden gebremst', async () => { const { port, close } = await start(); const socket = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], reconnection: false }); try { await new Promise((resolve) => socket.on('connect', resolve)); let limited = 0; socket.on('rate_limited', () => { limited += 1; }); for (let i = 0; i < 60; i++) socket.emit('overview:subscribe'); await new Promise((r) => setTimeout(r, 300)); assert.ok(limited > 0, 'ab einer Grenze werden Abfragen abgewiesen'); } finally { socket.close(); await close(); } }); test('Fehlgeschlagener Serverstart veröffentlicht keine Passwörter', async () => { class Failing extends DemoServerAdapter { async start({ secrets }) { const err = new Error(`Command failed: docker run -e CS2_RCONPW=${secrets.rconPassword} -e CS2_PW=${secrets.serverPassword} joedwards32/cs2`); err.stderr = ''; throw err; } } const { app, call, close } = await start({}, new Failing({ startSeconds: 0 })); try { const { id } = (await newMatch(call)).body; await app.manager.idle(); const row = app.manager.row(id); assert.equal(row.status, 'start_failed'); const pub = JSON.stringify((await call('GET', `/matches/${id}`)).body); for (const secret of [row.rcon_password, row.server_password]) { assert.ok(!pub.includes(secret), 'Passwort nicht in der öffentlichen Ansicht'); assert.ok(!row.message.includes(secret), 'Passwort nicht in der Meldung'); } assert.ok(!JSON.stringify(app.service.db.prepare('SELECT message FROM events WHERE match_id = ?').all(id)).includes(row.rcon_password)); } finally { await close(); } }); test('safeErrorMessage schwärzt Befehlszeile, Variablen und bekannte Geheimnisse', () => { const msg = safeErrorMessage({ message: 'Command failed: docker run -e CS2_RCONPW=abc123\nError: port is already allocated', stderr: '' }); assert.equal(msg, 'Error: port is already allocated'); assert.equal(safeErrorMessage({ stderr: 'bad TV_PW=geheim1 SRCDS_TOKEN=XYZ999' }), 'bad TV_PW=*** SRCDS_TOKEN=***'); assert.equal(safeErrorMessage(new Error('Server sagt Passwort1234 falsch'), ['Passwort1234']), 'Server sagt *** falsch'); }); test('Chat-Nachrichten können keine Map beenden oder starten', () => { const state = initialLiveState({ map: 'de_mirage', ctTeam: 'A', warmupEnded: true }); const ts = '11/01/2026 - 20:00:00.000 - '; const lines = [ `${ts}"Troll<3><[U:1:1]>" say "World triggered "Match_Start" on "de_mirage""`, `${ts}"Troll<3><[U:1:1]>" say "Team "CT" triggered "SFUI_Notice_CTs_Win" (CT "1") (T "0")"`, `${ts}"Troll<3><[U:1:1]>" say "Game Over: competitive mg_active de_mirage score 13:0 after 3 min"`, `${ts}"Game Over: x de_mirage score 13:0 after 3 min<4><[U:1:2]>" say "hi"`, ]; const { state: after, events } = applyLogLines(state, lines, { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 }); assert.deepEqual(events, []); assert.notEqual(after.status, 'finished'); assert.deepEqual(after.score, { A: 0, B: 0 }); // Die echte Systemzeile wirkt weiterhin const real = applyLogLines(after, [`${ts}Game Over: competitive mg_active de_mirage score 0:0 after 3 min`], { teamNames: { A: 'Alpha', B: 'Bravo' }, maxRounds: 24, overtimeRounds: 6 }); assert.equal(real.events[0]?.type, 'map_end'); }); test('Stopp oder Löschen während des Starts baut den verspäteten Container wieder ab', async () => { let release; const stopped = []; class Slow extends DemoServerAdapter { async start(args) { await new Promise((r) => { release = r; }); return super.start(args); } async stop(handle) { stopped.push(handle.matchId); return super.stop(handle); } } const { app, call, close } = await start({}, new Slow({ startSeconds: 0 })); try { const { id } = (await newMatch(call)).body; await new Promise((r) => setTimeout(r, 50)); await app.manager.stop(id, 'Test'); release(); await app.manager.idle(); assert.deepEqual(stopped, [id], 'verspäteter Container wurde gestoppt'); assert.equal(app.manager.row(id).status, 'stopped'); assert.equal(app.manager.row(id).handle, null); // Löschen während des Starts const second = (await newMatch(call)).body.id; await new Promise((r) => setTimeout(r, 50)); const deleting = app.manager.deleteMatch(second, 'Test'); await deleting; release(); await app.manager.idle(); assert.ok(stopped.includes(second)); } finally { await close(); } }); /** Kleiner RCON-Server, der nach der Anmeldung tut, was der Test vorgibt. */ async function fakeRcon(onCommand) { const server = net.createServer((socket) => { socket.on('data', (buf) => { const id = buf.readInt32LE(4); const type = buf.readInt32LE(8); if (type === 3) { const ok = Buffer.alloc(14); ok.writeInt32LE(10, 0); ok.writeInt32LE(id, 4); ok.writeInt32LE(2, 8); socket.write(ok); } else onCommand(socket, id); }); }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); return { port: server.address().port, close: () => server.close() }; } test('RCON: kaputte Pakete trennen nur die Verbindung, fehlende Antworten sind ein Fehler', async () => { const bad = await fakeRcon((socket) => { const junk = Buffer.alloc(8); junk.writeInt32LE(5, 0); // Paketgröße unter dem Minimum socket.write(junk); }); const silent = await fakeRcon(() => {}); try { const a = await new RconClient({ host: '127.0.0.1', port: bad.port, password: 'x', timeoutMs: 1000 }).connect(); await assert.rejects(a.command('status'), /geschlossen|keine Antwort/); a.close(); const b = await new RconClient({ host: '127.0.0.1', port: silent.port, password: 'x', timeoutMs: 300 }).connect(); await assert.rejects(b.command('mp_warmup_end'), /keine Antwort auf "mp_warmup_end"/); b.close(); } finally { bad.close(); silent.close(); } }); test('Organisatoren bekommen nur einen Stream-Schlüssel für ihr Match, nicht den globalen', async () => { const { call, close } = await start({ streamKey: 'GlobalerSchluessel42' }); try { const one = (await newMatch(call)).body; const two = (await newMatch(call)).body; const view = (await call('GET', `/matches/${one.id}`, { token: one.organizerToken })).body; const link = view.organizer.links.overview; assert.ok(!link.includes('GlobalerSchluessel42'), 'globaler Schlüssel nicht im Link'); const key = new URL(link).searchParams.get('key'); const scoped = (await call('GET', `/stream/overview?key=${encodeURIComponent(key)}`)).body; const ids = [...scoped.active, ...scoped.upcoming, ...scoped.results].map((m) => m.id); assert.deepEqual(ids, [one.id], 'nur das eigene Match'); assert.ok(!ids.includes(two.id)); // Gefälschter Schlüssel für ein anderes Match geht nicht assert.equal((await call('GET', `/stream/overview?key=${encodeURIComponent(`${two.id}.${key.split('.').pop()}`)}`)).status, 403); // Der globale Schlüssel zeigt weiter alles const all = (await call('GET', '/stream/overview?key=GlobalerSchluessel42')).body; assert.equal(all.upcoming.length, 2); } finally { await close(); } }); test('Rate-Limiter hält seinen Speicher begrenzt', () => { const check = createRateLimiter({ windowMs: 60_000, max: 5, maxKeys: 100 }); for (let i = 0; i < 1000; i++) check(`ip-${i}`); assert.ok(check.size() <= 100); for (let i = 0; i < 5; i++) assert.equal(check('x'), true); assert.equal(check('x'), false); });