import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import type { FastifyInstance } from 'fastify'; import { startTestDatabase, type TestDatabase } from './harness.js'; type Db = typeof import('../src/db.js'); let database: TestDatabase; let app: FastifyInstance; let db: Db; const PASSWORD = 'TestPasswort123'; interface Credentials { session: string; csrf: string; } async function login(username: string, password = PASSWORD): Promise { const response = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { username, password }, }); assert.equal(response.statusCode, 200, `Login von ${username} fehlgeschlagen`); const session = response.cookies.find((c) => c.name === 'ccm_sid'); const csrf = response.cookies.find((c) => c.name === 'ccm_csrf'); assert.ok(session && csrf, 'Sitzungs-Cookies fehlen'); return { session: session.value, csrf: csrf.value }; } function authHeaders(credentials: Credentials, withCsrf = true) { return { cookie: `ccm_sid=${credentials.session}; ccm_csrf=${credentials.csrf}`, ...(withCsrf ? { 'x-csrf-token': credentials.csrf } : {}), }; } before(async () => { database = await startTestDatabase(54330); process.env.DATABASE_URL = database.url; process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; process.env.NODE_ENV = 'test'; process.env.COOKIE_SECURE = 'false'; db = await import('../src/db.js'); const { hashPassword } = await import('../src/auth/password.js'); const { buildApp } = await import('../src/app.js'); const passwordHash = await hashPassword(PASSWORD); await db.prisma.user.createMany({ data: [ { username: 'chef', passwordHash, role: 'ADMIN' }, { username: 'team', passwordHash, role: 'EDITOR' }, { username: 'gast', passwordHash, role: 'VIEWER' }, { username: 'neu', passwordHash, role: 'EDITOR', mustChangePassword: true }, { username: 'gesperrt', passwordHash, role: 'EDITOR', isActive: false }, ], }); app = await buildApp(); await app.ready(); }); after(async () => { await app.close(); await db.prisma.$disconnect(); await database.stop(); }); describe('Anmeldung', () => { it('weist ein falsches Passwort ab', async () => { const response = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { username: 'chef', password: 'falsch' }, }); assert.equal(response.statusCode, 401); }); it('verrät nicht, ob es den Benutzernamen gibt', async () => { const unknownUser = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { username: 'gibtesnicht', password: 'falsch' }, }); const wrongPassword = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { username: 'chef', password: 'falsch' }, }); assert.equal(unknownUser.statusCode, wrongPassword.statusCode); assert.equal(unknownUser.json().message, wrongPassword.json().message); }); it('lässt ein gesperrtes Konto nicht herein', async () => { const response = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { username: 'gesperrt', password: PASSWORD }, }); assert.equal(response.statusCode, 401); }); it('setzt bei Erfolg beide Cookies', async () => { const credentials = await login('chef'); assert.ok(credentials.session.length > 20); assert.ok(credentials.csrf.length > 20); }); }); describe('CSRF-Schutz', () => { it('blockt eine schreibende Anfrage ohne Token', async () => { const credentials = await login('team'); const response = await app.inject({ method: 'POST', url: '/api/players', headers: authHeaders(credentials, false), payload: { displayName: 'Ohne Token' }, }); assert.equal(response.statusCode, 403); assert.equal(response.json().error, 'FORBIDDEN'); }); it('blockt einen gefälschten Token', async () => { const credentials = await login('team'); const response = await app.inject({ method: 'POST', url: '/api/players', headers: { ...authHeaders(credentials, false), 'x-csrf-token': 'a'.repeat(64) }, payload: { displayName: 'Falscher Token' }, }); assert.equal(response.statusCode, 403); }); it('lässt die Anfrage mit passendem Token durch', async () => { const credentials = await login('team'); const response = await app.inject({ method: 'POST', url: '/api/players', headers: authHeaders(credentials), payload: { displayName: 'Mit Token' }, }); assert.equal(response.statusCode, 201); }); it('lässt lesende Anfragen ohne Token zu', async () => { const credentials = await login('gast'); const response = await app.inject({ method: 'GET', url: '/api/players', headers: authHeaders(credentials, false), }); assert.equal(response.statusCode, 200); }); }); describe('Rollen', () => { it('ein Viewer darf lesen, aber nichts anlegen', async () => { const credentials = await login('gast'); const read = await app.inject({ method: 'GET', url: '/api/players', headers: authHeaders(credentials), }); const write = await app.inject({ method: 'POST', url: '/api/players', headers: authHeaders(credentials), payload: { displayName: 'Verboten' }, }); assert.equal(read.statusCode, 200); assert.equal(write.statusCode, 403); }); it('ein Editor kommt nicht in den Admin-Bereich', async () => { const credentials = await login('team'); const response = await app.inject({ method: 'GET', url: '/api/admin/users', headers: authHeaders(credentials), }); assert.equal(response.statusCode, 403); }); it('ein Admin kommt in den Admin-Bereich', async () => { const credentials = await login('chef'); const response = await app.inject({ method: 'GET', url: '/api/admin/users', headers: authHeaders(credentials), }); assert.equal(response.statusCode, 200); assert.ok(Array.isArray(response.json().items)); }); it('ohne Anmeldung ist alles zu', async () => { const response = await app.inject({ method: 'GET', url: '/api/players' }); assert.equal(response.statusCode, 401); }); }); describe('Der Zugang aus der Erstinstallation', () => { /** Legt einen geschützten Admin an, wie es der erste Start tut. */ async function erstzugang() { return db.prisma.user.create({ data: { username: 'erstzugang', passwordHash: 'x', role: 'ADMIN', isProtected: true, }, }); } it('lässt sich seine Rolle nicht nehmen', async () => { const geschuetzt = await erstzugang(); const credentials = await login('chef'); const response = await app.inject({ method: 'PATCH', url: `/api/admin/users/${geschuetzt.id}`, headers: authHeaders(credentials), payload: { role: 'VIEWER' }, }); assert.equal(response.statusCode, 409); assert.equal(response.json().error, 'PROTECTED_ACCOUNT'); const unveraendert = await db.prisma.user.findUniqueOrThrow({ where: { id: geschuetzt.id } }); assert.equal(unveraendert.role, 'ADMIN'); }); it('lässt sich nicht sperren', async () => { const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } }); const credentials = await login('chef'); const response = await app.inject({ method: 'PATCH', url: `/api/admin/users/${geschuetzt.id}`, headers: authHeaders(credentials), payload: { isActive: false }, }); assert.equal(response.statusCode, 409); }); it('bekommt sein Passwort nicht von aussen zurückgesetzt', async () => { const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } }); const credentials = await login('chef'); const response = await app.inject({ method: 'POST', url: `/api/admin/users/${geschuetzt.id}/reset-password`, headers: authHeaders(credentials), payload: { newPassword: 'EinLangesPasswort1' }, }); assert.equal(response.statusCode, 409); }); it('lässt sich nicht löschen', async () => { const geschuetzt = await db.prisma.user.findFirstOrThrow({ where: { isProtected: true } }); const credentials = await login('chef'); const response = await app.inject({ method: 'DELETE', url: `/api/admin/users/${geschuetzt.id}`, headers: authHeaders(credentials), }); assert.equal(response.statusCode, 409); assert.ok(await db.prisma.user.findUnique({ where: { id: geschuetzt.id } })); }); it('ist in der Liste als geschützt ausgewiesen', async () => { const credentials = await login('chef'); const response = await app.inject({ method: 'GET', url: '/api/admin/users', headers: authHeaders(credentials), }); const eintrag = response .json() .items.find((u: { username: string }) => u.username === 'erstzugang'); assert.equal(eintrag.isLocked, true, 'die Oberfläche soll die Knöpfe gar nicht erst zeigen'); }); it('lässt gewöhnliche Konten unangetastet', async () => { const credentials = await login('chef'); const normal = await db.prisma.user.findFirstOrThrow({ where: { username: 'gast' } }); const response = await app.inject({ method: 'PATCH', url: `/api/admin/users/${normal.id}`, headers: authHeaders(credentials), payload: { role: 'EDITOR' }, }); assert.equal(response.statusCode, 200, 'der Schutz gilt nur für den Erstzugang'); await app.inject({ method: 'PATCH', url: `/api/admin/users/${normal.id}`, headers: authHeaders(credentials), payload: { role: 'VIEWER' }, }); }); }); describe('Erstpasswort', () => { it('sperrt schreibende Aktionen bis zum Wechsel', async () => { const credentials = await login('neu'); const response = await app.inject({ method: 'POST', url: '/api/players', headers: authHeaders(credentials), payload: { displayName: 'Zu früh' }, }); assert.equal(response.statusCode, 403); assert.match(response.json().message, /Passwort/); }); it('erlaubt genau den Passwortwechsel', async () => { const credentials = await login('neu'); const response = await app.inject({ method: 'POST', url: '/api/account/password', headers: authHeaders(credentials), payload: { currentPassword: PASSWORD, newPassword: 'NeuesPasswort456' }, }); assert.equal(response.statusCode, 200); const user = await db.prisma.user.findUniqueOrThrow({ where: { username: 'neu' } }); assert.equal(user.mustChangePassword, false); }); }); describe('Guthaben über die API', () => { it('ein CC-Paket schlägt in beiden Töpfen auf', async () => { const credentials = await login('team'); const created = await app.inject({ method: 'POST', url: '/api/players', headers: authHeaders(credentials), payload: { displayName: 'API-Spieler' }, }); const playerId = created.json().id; const booked = await app.inject({ method: 'POST', url: `/api/players/${playerId}/credits/cc-package`, headers: authHeaders(credentials), payload: {}, }); assert.equal(booked.statusCode, 200); assert.deepEqual(booked.json().balances, { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0, redesignTotal: 1, // Was offen ist, und wovon: "1 von 1". granted: { CC: 1, REDESIGN_PAID: 1, REDESIGN_KULANZ: 0 }, }); }); it('eine unbekannte API-Route liefert sauberes JSON', async () => { const response = await app.inject({ method: 'GET', url: '/api/gibtesnicht' }); assert.equal(response.statusCode, 404); assert.equal(response.json().error, 'NOT_FOUND'); }); }); describe('Auslieferung der Oberfläche', () => { // Diese Header sind der Grund, warum ein Update beim Nutzer ankommt. Wird die // index.html cachebar ausgeliefert, sieht er nach einem Deploy weiter die // alte Oberfläche - ohne dass irgendwo ein Fehler auftaucht. it('lässt die index.html nicht zwischenspeichern', async () => { const response = await app.inject({ method: 'GET', url: '/' }); assert.equal(response.statusCode, 200); assert.match( response.headers['cache-control'] as string, /no-cache/, 'die index.html muss bei jedem Aufruf neu geholt werden', ); }); it('lässt Antworten der Schnittstelle gar nicht erst in den Cache', async () => { // Ohne Angabe entscheidet der Browser selbst, wie lange er eine Antwort // aufhebt - dann steht nach dem Anlegen eines CCs die alte Liste da. const response = await app.inject({ method: 'GET', url: '/api/auth/me' }); assert.equal(response.headers['cache-control'], 'no-store'); }); it('liefert auch Deep-Links ohne Cache aus', async () => { const response = await app.inject({ method: 'GET', url: '/players/1' }); assert.equal(response.statusCode, 200); assert.match(response.headers['cache-control'] as string, /no-cache/); }); it('lässt Bilder nicht ein Jahr lang kleben', async () => { // Sie tragen keinen Hash im Namen - ein Austausch muss ankommen. const response = await app.inject({ method: 'GET', url: '/favicon.png' }); assert.equal(response.statusCode, 200); const header = response.headers['cache-control'] as string; assert.doesNotMatch(header, /immutable/, 'kein immutable ohne Hash im Namen'); assert.match(header, /must-revalidate/); }); it('lässt gehashte Assets lange liegen', async () => { const page = await app.inject({ method: 'GET', url: '/' }); const asset = /\/assets\/[^"]+\.js/.exec(page.body)?.[0]; assert.ok(asset, 'die Seite verweist auf ein Asset'); const response = await app.inject({ method: 'GET', url: asset }); assert.equal(response.statusCode, 200); assert.match(response.headers['cache-control'] as string, /max-age=31536000/); }); });