Solo games run entirely in the browser, so the server previously had no idea who was playing right now (it only saw logins and finished games). This adds lightweight presence reporting and an admin dashboard. Presence (server): - POST /api/presence (logged-in users): heartbeat while a solo game runs, stores username, map (validated), difficulty, wave, since/lastSeen per user; entries expire automatically after 90s without a heartbeat - POST /api/presence/stop: explicit removal when the player exits Admin API (ADMIN_USERS env, comma-separated usernames): - GET /api/admin/overview: live solo players, multiplayer room summaries (code, mode, map, players — the server already tracks rooms), and global stats (accounts, rounds, crystals in circulation) - GET /api/admin/users?limit=100: user list with stats, newest login first - publicUser now carries an admin flag; non-admins get 403 Admin UI (src/components/AdminDashboard.vue, served at /admin): - Login gate for guests/non-admins (guest profiles are detected via isLoggedIn, not just user presence) - KPI cards, live solo table (player, map, difficulty, wave, duration), room table, and account table; auto-refresh every 5 seconds - App.vue renders the dashboard for /admin instead of the game and runs a screen watcher that starts/stops the solo presence heartbeat Config: ADMIN_USERS documented in docker-compose.yml and README. Tests: 6 new integration checks (admin flag, presence report/stop, 403 guard, overview contents, user list) — 37/37 green, build clean. Verified end-to-end in the browser: guest gate, admin login, and a live second player (map/difficulty/wave) appearing in the dashboard.
377 lines
16 KiB
JavaScript
377 lines
16 KiB
JavaScript
/**
|
|
* 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)
|