feat: account system, OIDC, meta-progression research tree, and automated tests
Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
uses MAX(level, new) to preserve higher account levels, ignores unknown
upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
upgrades level within transaction, returns full user object on success
Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
Defense (bonus_lives, shockwave, fortress_shield),
Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
+floor(score/250), minimum 1 crystal per game
REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
case-insensitive uniqueness; auto-creates user_stats row; returns
session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
challenge/verifier; discovers .well-known/openid-configuration;
verifies RS256 id_token signature via JWKS public key; validates
issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
updates user_stats (games_played, games_won, total_kills, total_score,
highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
no-store on API responses, path-traversal protection on static serving
Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards
Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
validation, OIDC single sign-on button (shown when configured),
guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
user display name, research and login/logout buttons, reactive
auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
per-upgrade cost/level display, purchase confirmation, disabled
state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
redirect detection (?auth=ok/?auth=error), guest profile migration
on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound
Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
(production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm
Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
scrypt hash/verify roundtrip, timing-safe constant-time comparison,
user creation with lowercase enforcement, UNIQUE constraint, session
create/get/delete lifecycle, expired session invalidation, crystal
addition, 5-level upgrade cost progression with max-level guard,
guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
upgrade rejection), game result stats accumulation, calcCrystalsEarned
formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
isolated DATA_DIR, exercises full REST flow – register validation
(username too short, password too short, duplicate, case-insensitive),
login (wrong password, correct), session cookie attributes (HttpOnly,
SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
crystals, unknown ID, successful purchase), game finish rewards
(victory, defeat, negative values clamped to 1 crystal minimum),
guest merge (crystals, level caps, unknown upgrades), logout,
OIDC-disabled endpoints (400), unauthenticated guards (401), 404
routing, CSP header on static files, rate limiting (429 after 20+
auth attempts per minute), and persistence across server restart
(kill + respawn with same DATA_DIR preserves all state)
This commit is contained in:
parent
ce2484bb8d
commit
1a9ac5bf45
24 changed files with 2216 additions and 12 deletions
322
scripts/test-auth.mjs
Normal file
322
scripts/test-auth.mjs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* 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' },
|
||||
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))
|
||||
})
|
||||
|
||||
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)
|
||||
168
scripts/test-db.mjs
Normal file
168
scripts/test-db.mjs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Unit tests for server/db.mjs (SQLite persistence layer) and the shared
|
||||
* crystal formula. Runs fully isolated in a temp DATA_DIR – no server needed.
|
||||
* Run: node scripts/test-db.mjs
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trxtd-db-test-'))
|
||||
process.env.DATA_DIR = tmp
|
||||
|
||||
const db = await import('../server/db.mjs')
|
||||
const { calcCrystalsEarned } = await import('../shared/meta-upgrades.mjs')
|
||||
|
||||
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)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- passwords
|
||||
await check('scrypt: korrektes Passwort verifiziert', async () => {
|
||||
const hash = await db.hashPassword('supersicher123')
|
||||
if (await db.verifyPassword('supersicher123', hash) !== true) throw new Error('sollte true sein')
|
||||
})
|
||||
await check('scrypt: falsches Passwort abgelehnt', async () => {
|
||||
const hash = await db.hashPassword('supersicher123')
|
||||
if (await db.verifyPassword('falschfalsch', hash) !== false) throw new Error('sollte false sein')
|
||||
})
|
||||
await check('scrypt: kaputte/fehlende Hashes sind false', async () => {
|
||||
if (await db.verifyPassword('x', null) !== false) throw new Error('null-Hash')
|
||||
if (await db.verifyPassword('x', 'kein-doppelpunkt') !== false) throw new Error('ohne Doppelpunkt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- users
|
||||
let alice = null
|
||||
await check('createUserLocal: Nutzer mit Stats & 0 Kristallen', async () => {
|
||||
alice = db.createUserLocal('Alice_01', 'Alice', await db.hashPassword('passwort123'))
|
||||
if (!alice) throw new Error('kein Nutzer')
|
||||
if (alice.username !== 'alice_01') throw new Error('username nicht lowercase')
|
||||
if (alice.crystals !== 0) throw new Error('crystals != 0')
|
||||
eq(alice.upgrades, {}, 'upgrades nicht leer')
|
||||
eq(alice.stats, { gamesPlayed: 0, gamesWon: 0, totalKills: 0, totalScore: 0, highestWave: 0 })
|
||||
})
|
||||
await check('getUserByUsername: case-insensitiv, unbekannt = null', () => {
|
||||
if (!db.getUserByUsername('ALICE_01')) throw new Error('sollte gefunden werden')
|
||||
if (db.getUserByUsername('nobody')) throw new Error('sollte null sein')
|
||||
})
|
||||
await check('createUserLocal: doppelter Username wirft', async () => {
|
||||
let threw = false
|
||||
try {
|
||||
db.createUserLocal('alice_01', 'Alice2', await db.hashPassword('passwort123'))
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
if (!threw) throw new Error('UNIQUE-Constraint nicht ausgelöst')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- sessions
|
||||
let bob = null
|
||||
await check('Sessions: create/get/delete Zyklus', async () => {
|
||||
bob = db.createUserLocal('bob', 'Bob', await db.hashPassword('passwort123'))
|
||||
const sess = db.createSession(bob.id)
|
||||
const found = db.getSession(sess.token)
|
||||
if (!found || found.user_id !== bob.id) throw new Error('Session nicht gefunden')
|
||||
if (db.getSession('falscher-token')) throw new Error('falscher Token sollte null sein')
|
||||
db.deleteSession(sess.token)
|
||||
if (db.getSession(sess.token)) throw new Error('Session sollte gelöscht sein')
|
||||
})
|
||||
await check('Sessions: abgelaufene Session ungültig + Cleanup', () => {
|
||||
const expired = db.createSession(bob.id, -0.001) // läuft sofort ab
|
||||
if (db.getSession(expired.token)) throw new Error('abgelaufene Session darf nicht gelten')
|
||||
db.cleanExpiredSessions() // darf nicht werfen
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- crystals & upgrades
|
||||
await check('addCrystals: summiert Kristalle', () => {
|
||||
const after = db.addCrystals(bob.id, 2000)
|
||||
if (after.crystals !== 2000) throw new Error(`crystals ${after.crystals}`)
|
||||
})
|
||||
await check('buyUpgrade: zu wenig Kristalle abgelehnt', () => {
|
||||
const poor = db.createUserLocal('poor', 'Poor', 'x'.repeat(40))
|
||||
const r = db.buyUpgrade(poor.id, 'start_gold', 30, 5)
|
||||
if (r.ok) throw new Error('sollte fehlschlagen')
|
||||
if (!r.error.includes('Kristalle')) throw new Error(`unerwartete Fehlermeldung: ${r.error}`)
|
||||
})
|
||||
await check('buyUpgrade: 5 Stufen zu korrekten Kosten, dann Max erreicht', () => {
|
||||
const costs = [30, 70, 150, 300, 600]
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = db.buyUpgrade(bob.id, 'start_gold', costs[i], 5)
|
||||
if (!r.ok) throw new Error(`Stufe ${i + 1} fehlgeschlagen: ${r.error}`)
|
||||
if (r.user.upgrades.start_gold !== i + 1) throw new Error(`falsche Stufe ${r.user.upgrades.start_gold}`)
|
||||
if (r.user.crystals !== 2000 - costs.slice(0, i + 1).reduce((a, b) => a + b, 0)) {
|
||||
throw new Error(`falscher Kristallstand: ${r.user.crystals}`)
|
||||
}
|
||||
}
|
||||
const over = db.buyUpgrade(bob.id, 'start_gold', 1, 5)
|
||||
if (over.ok) throw new Error('Maximalstufe nicht erkannt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- guest merge
|
||||
await check('mergeGuest: Kristalle gedeckelt, Levels gedeckelt, Unbekanntes ignoriert', () => {
|
||||
const guest = db.createUserLocal('guest', 'Guest', 'x'.repeat(40))
|
||||
const maxLevels = { start_gold: 5, dot_potency: 3 }
|
||||
const merged = db.mergeGuest(guest.id, 999999, { start_gold: 99, dot_potency: 2, nonexistent: 5 }, maxLevels)
|
||||
if (merged.crystals !== 100000) throw new Error(`Kristalle nicht auf 100000 gedeckelt: ${merged.crystals}`)
|
||||
if (merged.upgrades.start_gold !== 5) throw new Error(`start_gold nicht auf 5 gedeckelt: ${merged.upgrades.start_gold}`)
|
||||
if (merged.upgrades.dot_potency !== 2) throw new Error(`dot_potency falsch: ${merged.upgrades.dot_potency}`)
|
||||
if (merged.upgrades.nonexistent) throw new Error('unbekanntes Upgrade darf nicht übernommen werden')
|
||||
})
|
||||
await check('mergeGuest: negatives/ungültiges Input wird neutralisiert', () => {
|
||||
const guest = db.createUserLocal('guest2', 'Guest2', 'x'.repeat(40))
|
||||
const maxLevels = { start_gold: 5 }
|
||||
const merged = db.mergeGuest(guest.id, -50, { start_gold: -3 }, maxLevels)
|
||||
if (merged.crystals !== 0) throw new Error('negative Kristalle müssen 0 ergeben')
|
||||
if (merged.upgrades.start_gold) throw new Error('negative Level müssen ignoriert werden')
|
||||
})
|
||||
await check('mergeGuest: höherer Account-Level bleibt erhalten (MAX)', () => {
|
||||
const rich = db.createUserLocal('rich', 'Rich', 'x'.repeat(40))
|
||||
db.addCrystals(rich.id, 200)
|
||||
db.buyUpgrade(rich.id, 'start_gold', 30, 5)
|
||||
db.buyUpgrade(rich.id, 'start_gold', 70, 5)
|
||||
const merged = db.mergeGuest(rich.id, 10, { start_gold: 1 }, { start_gold: 5 })
|
||||
if (merged.upgrades.start_gold !== 2) throw new Error(`Gast-Level 1 darf Account-Level 2 nicht überschreiben: ${merged.upgrades.start_gold}`)
|
||||
if (merged.crystals !== 110) throw new Error(`Kristalle: ${merged.crystals}`)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- game results
|
||||
await check('recordGameResult: Kristalle & Statistiken akkumulieren', () => {
|
||||
const fresh = db.createUserLocal('stats', 'Stats', 'x'.repeat(40))
|
||||
let u = db.recordGameResult(fresh.id, { win: true, score: 3000, wave: 12, kills: 55, crystalsEarned: 82 })
|
||||
if (u.crystals !== 82) throw new Error(`crystals ${u.crystals}`)
|
||||
u = db.recordGameResult(fresh.id, { win: false, score: 500, wave: 5, kills: 10, crystalsEarned: 14 })
|
||||
if (u.crystals !== 96) throw new Error(`crystals ${u.crystals}`)
|
||||
eq(u.stats, { gamesPlayed: 2, gamesWon: 1, totalKills: 65, totalScore: 3500, highestWave: 12 })
|
||||
})
|
||||
await check('recordGameResult: Verlust mit crystalsEarned 0 erhöht keine Kristalle', () => {
|
||||
const fresh = db.createUserLocal('noloss', 'NoLoss', 'x'.repeat(40))
|
||||
const u = db.recordGameResult(fresh.id, { win: false, score: 0, wave: 1, kills: 0, crystalsEarned: 0 })
|
||||
if (u.crystals !== 0) throw new Error(`crystals ${u.crystals}`)
|
||||
if (u.stats.gamesPlayed !== 1) throw new Error('Statistik nicht hochgezählt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- crystal formula
|
||||
await check('calcCrystalsEarned: Formel deckungsgleich mit Definition', () => {
|
||||
eq(calcCrystalsEarned(12, 3000, true), 82)
|
||||
eq(calcCrystalsEarned(5, 500, false), 14)
|
||||
eq(calcCrystalsEarned(0, 0, false), 1, 'Minimum 1 Kristall pro Runde')
|
||||
eq(calcCrystalsEarned(10, 1000, false), 29)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- teardown
|
||||
db.closeDb()
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
|
||||
console.log(`\n${passed} bestanden, ${failed} fehlgeschlagen`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue