/** * End-to-end integration tests for the account & meta-progression REST API. * * Spawns the real server (server/server.mjs) against an isolated SQLite * database in a temp dir, then exercises registration, login, sessions, * upgrade purchases, game-finish rewards, guest migration, logout and the * (disabled) OIDC endpoints over plain HTTP. * * Run: node scripts/test-auth.mjs (standalone, no server needed) */ import { spawn } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const PORT = 31000 + Math.floor(Math.random() * 500) const BASE = `http://127.0.0.1:${PORT}` const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trxtd-auth-test-')) let child = null function startServer() { child = spawn(process.execPath, ['server/server.mjs'], { cwd: ROOT, env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false', ADMIN_USERS: 'siteadmin' }, stdio: ['ignore', 'ignore', 'pipe'], }) child.stderr.on('data', () => {}) // drain (experimental-warning etc.) } startServer() let passed = 0 let failed = 0 async function check(name, fn) { try { await fn() passed++ console.log(` OK ${name}`) } catch (e) { failed++ console.error(` FAIL ${name}: ${e.message}`) } } const eq = (a, b, msg) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${msg || 'ungleich'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`) } const is = (cond, msg) => { if (!cond) throw new Error(msg) } async function waitForServer() { for (let i = 0; i < 100; i++) { try { const r = await fetch(`${BASE}/health`) if (r.status === 200) return } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 100)) } throw new Error('Server startete nicht innerhalb von 10s') } // minimal cookie jar: remembers Set-Cookie, sends Cookie on later requests function makeClient() { const jar = new Map() const remember = (res) => { const sc = res.headers.get('set-cookie') if (sc) { const m = sc.match(/^([^=;]+)=([^;]*)/) if (m) jar.set(m[1], m[2]) } } const req = async (method, p, body) => { const headers = {} const cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ') if (cookie) headers.cookie = cookie let payload if (body !== undefined) { headers['content-type'] = 'application/json' payload = JSON.stringify(body) } const res = await fetch(`${BASE}${p}`, { method, headers, body: payload, redirect: 'manual' }) remember(res) let json = null try { json = await res.json() } catch { /* non-json body */ } return { status: res.status, json, cookieHeader: res.headers.get('set-cookie') } } return { req, jar } } const zeroStats = { gamesPlayed: 0, gamesWon: 0, totalKills: 0, totalScore: 0, highestWave: 0 } const anon = makeClient() try { await waitForServer() await check('Server: /health antwortet mit 200', async () => { const r = await fetch(`${BASE}/health`) is(r.status === 200, `status ${r.status}`) }) await check('Config: OIDC deaktiviert gemeldet', async () => { const { status, json } = await anon.req('GET', '/api/config') is(status === 200, `status ${status}`) eq(json, { oidcEnabled: false, oidcLabel: 'Mit Single Sign-On anmelden' }) }) await check('Auth: /me ohne Cookie -> user null', async () => { const { status, json } = await anon.req('GET', '/api/auth/me') eq({ status, json }, { status: 200, json: { user: null } }) }) await check('Register: zu kurzer Benutzername -> 400', async () => { const { status, json } = await anon.req('POST', '/api/auth/register', { username: 'ab', password: 'abcdefgh' }) is(status === 400, `status ${status}`) is(json.error.includes('Benutzername'), `error ${json.error}`) }) await check('Register: zu kurzes Passwort -> 400', async () => { const { status, json } = await anon.req('POST', '/api/auth/register', { username: 'valid_user1', password: 'kurz' }) is(status === 400, `status ${status}`) is(json.error.includes('Passwort'), `error ${json.error}`) }) const client = makeClient() let registerRes = null await check('Register: Konto anlegen -> 200, Session-Cookie, leeres Profil', async () => { registerRes = await client.req('POST', '/api/auth/register', { username: 'TestUser', password: 'geheim123', displayName: 'Tester', }) is(registerRes.status === 200, `status ${registerRes.status}`) const u = registerRes.json.user is(u.username === 'testuser', `username ${u.username}`) is(u.displayName === 'Tester', `displayName ${u.displayName}`) is(u.crystals === 0, `crystals ${u.crystals}`) is(u.oidc === false, 'oidc sollte false sein') eq(u.upgrades, {}) eq(u.stats, zeroStats) is(/^trxtd_session=[^;]+; Path=\/; HttpOnly; SameSite=Lax/.test(registerRes.cookieHeader || ''), `cookie ${registerRes.cookieHeader}`) }) await check('Auth: /me mit Session-Cookie liefert Nutzer', async () => { const { status, json } = await client.req('GET', '/api/auth/me') is(status === 200 && json.user?.username === 'testuser', JSON.stringify(json)) }) await check('Register: doppelter Benutzername -> 409', async () => { const { status } = await anon.req('POST', '/api/auth/register', { username: 'testuser', password: 'geheim123' }) is(status === 409, `status ${status}`) }) await check('Register: Groß-/Kleinschreibung egal -> 409', async () => { const { status } = await anon.req('POST', '/api/auth/register', { username: 'TESTUSER', password: 'geheim123' }) is(status === 409, `status ${status}`) }) await check('Login: falsches Passwort -> 401', async () => { const { status, json } = await anon.req('POST', '/api/auth/login', { username: 'testuser', password: 'falsch123' }) is(status === 401, `status ${status}`) is(json.error.includes('falsch'), `error ${json.error}`) }) await check('Login: korrekt -> 200, frisches Session-Cookie', async () => { const { status, json, cookieHeader } = await client.req('POST', '/api/auth/login', { username: 'TestUser', password: 'geheim123' }) is(status === 200 && json.user?.username === 'testuser', JSON.stringify(json)) is(cookieHeader && !cookieHeader.includes(registerRes.cookieHeader), 'Cookie-Token nicht erneuert') }) await check('Upgrades: Kauf ohne Anmeldung -> 401', async () => { const { status } = await anon.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) is(status === 401, `status ${status}`) }) await check('Upgrades: Kauf ohne Kristalle -> 400', async () => { const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) is(status === 400, `status ${status}`) is(json.error.includes('Kristalle'), `error ${json.error}`) }) await check('Game: Sieg in Welle 12 -> 82 Kristalle & Statistiken', async () => { const { status, json } = await client.req('POST', '/api/game/finish', { win: true, wave: 12, score: 3000, kills: 55 }) is(status === 200, `status ${status}`) is(json.crystalsEarned === 82, `crystalsEarned ${json.crystalsEarned}`) is(json.user.crystals === 82, `crystals ${json.user.crystals}`) eq(json.user.stats, { gamesPlayed: 1, gamesWon: 1, totalKills: 55, totalScore: 3000, highestWave: 12 }) }) await check('Game: Niederlage in Welle 5 -> 14 Kristalle, Stats akkumulieren', async () => { const { status, json } = await client.req('POST', '/api/game/finish', { win: false, wave: 5, score: 500, kills: 10 }) is(status === 200 && json.crystalsEarned === 14, JSON.stringify(json)) is(json.user.crystals === 96, `crystals ${json.user.crystals}`) eq(json.user.stats, { gamesPlayed: 2, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 }) }) await check('Game: negative Werte werden geklemmt (min. 1 Kristall)', async () => { const { status, json } = await client.req('POST', '/api/game/finish', { win: false, wave: -5, score: -100, kills: -3 }) is(status === 200 && json.crystalsEarned === 1, JSON.stringify(json)) is(json.user.crystals === 97, `crystals ${json.user.crystals}`) eq(json.user.stats, { gamesPlayed: 3, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 }) }) await check('Upgrades: Startkapital Stufe 1 kaufen -> 67 Kristalle', async () => { const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) is(status === 200, `status ${status}`) is(json.user.upgrades.start_gold === 1 && json.user.crystals === 67, JSON.stringify(json)) }) await check('Upgrades: Festungsmauern Stufe 1 kaufen -> 42 Kristalle', async () => { const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'bonus_lives' }) is(status === 200, `status ${status}`) is(json.user.upgrades.bonus_lives === 1 && json.user.crystals === 42, JSON.stringify(json)) }) await check('Upgrades: Stufe 2 (70) nicht bezahlbar -> 400', async () => { const { status } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'start_gold' }) is(status === 400, `status ${status}`) }) await check('Upgrades: unbekanntes Upgrade -> 400', async () => { const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'hack_alles' }) is(status === 400 && json.error.includes('Unbekannt'), JSON.stringify(json)) }) await check('Merge-Guest: Kristalle & Level gemergt, Stufen gedeckelt', async () => { const { status, json } = await client.req('POST', '/api/auth/merge-guest', { crystals: 40, upgrades: { start_gold: 2, dot_potency: 99, nonexistent: 7 }, }) is(status === 200, `status ${status}`) is(json.user.crystals === 82, `crystals ${json.user.crystals}`) is(json.user.upgrades.start_gold === 2, `start_gold ${json.user.upgrades.start_gold}`) is(json.user.upgrades.dot_potency === 3, `dot_potency ${json.user.upgrades.dot_potency}`) is(!json.user.upgrades.nonexistent, 'unbekanntes Upgrade übernommen') }) await check('Upgrades: nach Merge neue Stufe kaufbar -> 42 Kristalle', async () => { const { status, json } = await client.req('POST', '/api/upgrades/buy', { upgradeId: 'wave_bonus' }) is(status === 200, `status ${status}`) is(json.user.upgrades.wave_bonus === 1 && json.user.crystals === 42, JSON.stringify(json)) }) // ------------------------------------------------------- admin & presence const admin = makeClient() await check('Admin: ADMIN_USERS-Konto bekommt admin-Flag', async () => { const { status, json } = await admin.req('POST', '/api/auth/register', { username: 'siteadmin', password: 'adminpass123', displayName: 'Site Admin', }) is(status === 200 && json.user.admin === true, JSON.stringify(json)) const me = await client.req('GET', '/api/auth/me') is(me.json.user.admin === false, 'Normaler Account darf kein Admin sein') }) await check('Presence: Solo-Spiel wird gemeldet', async () => { const { status, json } = await client.req('POST', '/api/presence', { mode: 'solo', map: 'volcano', difficulty: 'hard', wave: 7, }) eq({ status, json }, { status: 200, json: { ok: true } }) }) await check('Admin: Übersicht ohne Admin-Rechte -> 403', async () => { const { status } = await client.req('GET', '/api/admin/overview') is(status === 403, `status ${status}`) }) await check('Admin: Übersicht zeigt Live-Solo-Spieler & Räume', async () => { const { status, json } = await admin.req('GET', '/api/admin/overview') is(status === 200, `status ${status}`) const p = json.soloPlayers.find((x) => x.username === 'testuser') if (!p) throw new Error('testuser nicht in soloPlayers') is(p.map === 'volcano', `map ${p.map}`) is(p.difficulty === 'hard', `difficulty ${p.difficulty}`) is(p.wave === 7, `wave ${p.wave}`) is(Array.isArray(json.rooms), 'rooms fehlen') is(json.stats.users >= 2, `stats.users ${json.stats.users}`) }) await check('Admin: Nutzerliste mit Statistiken', async () => { const { status, json } = await admin.req('GET', '/api/admin/users') is(status === 200 && json.users.length >= 2, JSON.stringify({ status, n: json.users?.length })) const t = json.users.find((u) => u.username === 'testuser') if (!t) throw new Error('testuser nicht in Nutzerliste') is(t.stats.gamesPlayed === 3, `gamesPlayed ${t.stats.gamesPlayed}`) }) await check('Presence: Stop entfernt Spieler aus Live-Übersicht', async () => { const stop = await client.req('POST', '/api/presence/stop') is(stop.status === 200, `status ${stop.status}`) const { json } = await admin.req('GET', '/api/admin/overview') is(!json.soloPlayers.some((x) => x.username === 'testuser'), 'testuser noch aktiv') }) await check('OIDC: Login-Endpunkt ohne Aktivierung -> 400', async () => { const { status, json } = await anon.req('GET', '/api/auth/oidc/login') is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json)) }) await check('OIDC: Callback ohne Aktivierung -> 400', async () => { const { status } = await anon.req('GET', '/api/auth/oidc/callback?code=abc&state=xyz') is(status === 400, `status ${status}`) }) await check('Logout: Session invalidiert, /me danach null', async () => { const out = await client.req('POST', '/api/auth/logout') is(out.status === 200, `status ${out.status}`) const me = await client.req('GET', '/api/auth/me') is(me.status === 200 && me.json.user === null, JSON.stringify(me.json)) }) await check('Auth: Merge-Guest ohne Anmeldung -> 401', async () => { const { status } = await anon.req('POST', '/api/auth/merge-guest', { crystals: 1, upgrades: {} }) is(status === 401, `status ${status}`) }) await check('Game: Ergebnis ohne Anmeldung -> 401', async () => { const { status } = await anon.req('POST', '/api/game/finish', { win: true, wave: 1, score: 10, kills: 1 }) is(status === 401, `status ${status}`) }) await check('API: unbekannter Pfad -> 404 JSON (angemeldet)', async () => { const login = await client.req('POST', '/api/auth/login', { username: 'testuser', password: 'geheim123' }) is(login.status === 200, `relogin ${login.status}`) const { status, json } = await client.req('GET', '/api/gibtsnicht') is(status === 404 && json.error, JSON.stringify({ status, json })) }) if (fs.existsSync(path.join(ROOT, 'dist', 'index.html'))) { await check('Static: / liefert index.html mit CSP-Header', async () => { const r = await fetch(`${BASE}/`) is(r.status === 200, `status ${r.status}`) is((r.headers.get('content-security-policy') || '').includes("default-src 'self'"), 'CSP fehlt') }) } await check('Rate-Limit: >20 Auth-Versuche/Minute -> 429', async () => { let seen429 = false for (let i = 0; i < 30 && !seen429; i++) { const { status } = await anon.req('POST', '/api/auth/register', { username: 'x', password: 'x' }) if (status === 429) seen429 = true } is(seen429, 'kein 429 nach 30 Versuchen') }) await check('Persistenz: Server-Neustart behält Konto & Kristalle', async () => { // restart with the same DATA_DIR — in-memory rate-limit resets, SQLite persists child.kill('SIGTERM') await new Promise((r) => setTimeout(r, 400)) startServer() await waitForServer() const { status, json } = await client.req('POST', '/api/auth/login', { username: 'testuser', password: 'geheim123' }) is(status === 200, `status ${status}`) is(json.user?.crystals === 42, `crystals ${json.user?.crystals}`) is(json.user?.upgrades.start_gold === 2, `start_gold ${json.user?.upgrades.start_gold}`) is(json.user?.upgrades.bonus_lives === 1, `bonus_lives ${json.user?.upgrades.bonus_lives}`) is(json.user?.upgrades.dot_potency === 3, `dot_potency ${json.user?.upgrades.dot_potency}`) is(json.user?.upgrades.wave_bonus === 1, `wave_bonus ${json.user?.upgrades.wave_bonus}`) }) } finally { child.kill('SIGTERM') await new Promise((r) => setTimeout(r, 300)) fs.rmSync(tmp, { recursive: true, force: true }) } console.log(`\n${passed} bestanden, ${failed} fehlgeschlagen`) process.exit(failed === 0 ? 0 : 1)