import { beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../src/server.js'; import { one, query, run } from '../src/core/db.js'; import { verifyAuditChain } from '../src/core/audit.js'; import { call, code, login, makeUser, nextCode } from './helpers.js'; let app: FastifyInstance; beforeAll(async () => { app = await buildApp(); await app.ready(); }); async function staffWithMfa(email: string, role: string) { await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email); const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); const conf = await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) }); expect(conf.statusCode).toBe(200); expect(conf.json().recoveryCodes).toHaveLength(10); return { client, secret: setup.secret as string }; } describe('Login & Sitzungen', () => { it('lehnt falsches Passwort generisch ab und protokolliert', async () => { await makeUser({ email: 'a@example.test' }); const r = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'a@example.test', password: 'falsch-falsch-falsch' } }); expect(r.statusCode).toBe(401); const r2 = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'unbekannt@example.test', password: 'falsch-falsch-falsch' } }); expect(r2.json().error.code).toBe(r.json().error.code); expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='auth.login' AND result='denied'"))!.n).toBeGreaterThanOrEqual(2); }); it('sperrt das Konto nach 5 Fehlversuchen', async () => { await makeUser({ email: 'lock@example.test' }); for (let i = 0; i < 5; i++) await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'falsch-falsch-falsch' } }); const ok = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email: 'lock@example.test', password: 'correct-horse-battery' } }); expect(ok.statusCode).toBe(401); }); it('verlangt CSRF-Token bei schreibenden Requests', async () => { await makeUser({ email: 'csrf@example.test' }); const { client } = await login(app, 'csrf@example.test'); const r = await app.inject({ method: 'POST', url: '/v1/auth/logout', headers: { cookie: client.cookie } }); expect(r.statusCode).toBe(403); expect(r.json().error.code).toBe('BAD_CSRF'); }); it('kann Sitzungen widerrufen', async () => { await makeUser({ email: 's@example.test' }); const a = (await login(app, 's@example.test')).client; const b = (await login(app, 's@example.test')).client; const list = (await call(app, a, 'GET', '/auth/sessions')).json(); expect(list).toHaveLength(2); const other = list.find((s: any) => !s.current); expect((await call(app, a, 'DELETE', `/auth/sessions/${other.id}`)).statusCode).toBe(200); expect((await call(app, b, 'GET', '/auth/me')).statusCode).toBe(401); }); }); describe('2FA', () => { it('erzwingt 2FA-Einrichtung für Staff und TOTP beim Login', async () => { await makeUser({ email: 'staff1@example.test', kind: 'staff', staffRole: 'admin' }); const { client } = await login(app, 'staff1@example.test'); expect((await call(app, client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_ENROLL_REQUIRED'); const setup = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); expect((await call(app, client, 'POST', '/auth/mfa/confirm', { code: '000000' })).statusCode).toBe(400); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(setup.secret) }); expect((await call(app, client, 'GET', '/admin/customers')).statusCode).toBe(200); // neuer Login: Passwort allein reicht nicht const l2 = await login(app, 'staff1@example.test'); expect(l2.res.json().status).toBe('mfa_required'); expect((await call(app, l2.client, 'GET', '/admin/customers')).json().error.code).toBe('MFA_REQUIRED'); expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: '123456' })).statusCode).toBe(401); expect((await call(app, l2.client, 'POST', '/auth/mfa/verify', { code: nextCode(setup.secret) })).statusCode).toBe(200); expect((await call(app, l2.client, 'GET', '/admin/customers')).statusCode).toBe(200); }); it('speichert das TOTP-Secret nur verschlüsselt', async () => { const r = await one('SELECT secret_enc FROM mfa_totp LIMIT 1'); expect(r!.secret_enc).toMatch(/^v1:/); }); }); describe('Kundenanlage & Mandantentrennung', () => { it('legt Kunden an, lädt Owner ein, und trennt Mandanten strikt', async () => { const { client: admin } = await staffWithMfa('admin@example.test', 'admin'); const mk = async (name: string, mail: string) => (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name }, billing: { city: 'Berlin' } })).json(); const A = await mk('Firma A', 'owner-a@example.test'); const B = await mk('Firma B', 'owner-b@example.test'); expect(A.customerNumber).toMatch(/^K-\d+$/); expect(A.customerNumber).not.toBe(B.customerNumber); expect(A.inviteLink).toContain('/einladung?token='); // Einladung annehmen for (const [inv, pw] of [[A.inviteLink, 'passwort-owner-a1'], [B.inviteLink, 'passwort-owner-b1']] as const) { const token = new URL(inv).searchParams.get('token'); expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: pw, repeat: pw } })).statusCode).toBe(200); } const ownerA = (await login(app, 'owner-a@example.test', 'passwort-owner-a1')).client; expect((await call(app, ownerA, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200); expect((await call(app, ownerA, 'GET', `/orgs/${B.id}`)).statusCode).toBe(404); // fremde Org expect((await call(app, ownerA, 'GET', `/orgs/${B.id}/members`)).statusCode).toBe(404); expect((await call(app, ownerA, 'POST', `/orgs/${B.id}/invitations`, { email: 'x@example.test', name: 'X', role: 'member' })).statusCode).toBe(404); expect((await call(app, ownerA, 'GET', '/admin/customers')).statusCode).toBe(403); // keine Staff-Rechte expect((await call(app, ownerA, 'GET', '/admin/users')).statusCode).toBe(403); // Einladung nur einmal nutzbar const token = new URL(A.inviteLink).searchParams.get('token'); expect((await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token, password: 'anderes-passwort-12', repeat: 'anderes-passwort-12' } })).statusCode).toBe(400); // Mitglied einladen; Member darf nicht einladen const inv = (await call(app, ownerA, 'POST', `/orgs/${A.id}/invitations`, { email: 'm@example.test', name: 'M', role: 'member' })).json(); await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(inv.inviteLink).searchParams.get('token'), password: 'member-passwort-1', repeat: 'member-passwort-1' } }); const member = (await login(app, 'm@example.test', 'member-passwort-1')).client; expect((await call(app, member, 'GET', `/orgs/${A.id}`)).statusCode).toBe(200); expect((await call(app, member, 'POST', `/orgs/${A.id}/invitations`, { email: 'y@example.test', name: 'Y', role: 'member' })).statusCode).toBe(403); // Discord-Job wurde idempotent eingereiht, ohne personenbezogene Daten const jobs = await query("SELECT payload FROM jobs WHERE type='discord.notify' AND idempotency_key IN (?, ?)", [`customer.created:${A.id}`, `customer.created:${B.id}`]); expect(jobs).toHaveLength(2); expect(JSON.stringify(jobs)).not.toContain('example.test'); }); it('verhindert doppelte E-Mail bei Kundenanlage', async () => { const { client: admin } = await staffWithMfa('admin2@example.test', 'admin'); const r = await call(app, admin, 'POST', '/admin/customers', { type: 'business', name: 'Dup', owner: { email: 'owner-a@example.test', name: 'Dup' } }); expect(r.statusCode).toBe(409); }); }); describe('Rechte', () => { it('support darf lesen, aber nicht Kunden anlegen; admin darf keine Admins anlegen', async () => { const { client: sup } = await staffWithMfa('support@example.test', 'support'); expect((await call(app, sup, 'GET', '/admin/customers')).statusCode).toBe(200); expect((await call(app, sup, 'POST', '/admin/customers', { type: 'business', name: 'N', owner: { email: 'n@example.test', name: 'N' } })).statusCode).toBe(403); expect((await call(app, sup, 'GET', '/admin/audit')).statusCode).toBe(403); const { client: adm } = await staffWithMfa('admin3@example.test', 'admin'); expect((await call(app, adm, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).json().error.code).toBe('PRIVILEGED_ONLY'); expect((await call(app, adm, 'POST', '/admin/users', { email: 'newsup@example.test', name: 'N', staffRole: 'support' })).statusCode).toBe(200); const { client: sa } = await staffWithMfa('super@example.test', 'superadmin'); expect((await call(app, sa, 'POST', '/admin/users', { email: 'newadmin@example.test', name: 'N', staffRole: 'admin' })).statusCode).toBe(200); }); }); describe('Audit', () => { it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt eine eingeschleuste Fälschung', async () => { expect((await verifyAuditChain()).brokenAt).toBeNull(); const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events')); expect(dump).not.toMatch(/passwort-owner|correct-horse/); // audit_events ist auf DB-Ebene unveränderlich (Migration 033); eine Manipulation ist daher nur noch als // eingeschleuste Fälschung denkbar (z. B. Wiedereinspielen einer alten Sicherung neben der echten Kette), // nicht mehr als nachträgliches UPDATE einer bestehenden Zeile. await run("INSERT INTO audit_events (actor_type, action, result, prev_hash, hash, hash_version) VALUES ('system','gefaelscht','success', REPEAT('0',64), REPEAT('f',64), 2)"); const fake = await one("SELECT id FROM audit_events WHERE action = 'gefaelscht'"); expect((await verifyAuditChain()).brokenAt).toBe(fake!.id); }); it('verweigert UPDATE und DELETE auf audit_events auf DB-Ebene (append-only)', async () => { const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1'); await expect(run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id])).rejects.toThrow(/unveraenderlich/); await expect(run('DELETE FROM audit_events WHERE id = ?', [first!.id])).rejects.toThrow(/unveraenderlich/); }); }); describe('Passwort-Wiederholung und 2FA zurücksetzen', () => { it('verlangt die Wiederholung des neuen Passworts', async () => { await makeUser({ email: 'pw@example.test' }); const { client } = await login(app, 'pw@example.test'); const bad = await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-124' }); expect(bad.statusCode).toBe(400); expect(bad.json().error.code).toBe('PASSWORD_MISMATCH'); expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123' })).statusCode).toBe(400); // repeat fehlt expect((await call(app, client, 'POST', '/auth/password/change', { current: 'correct-horse-battery', next: 'neues-passwort-123', repeat: 'neues-passwort-123' })).statusCode).toBe(200); expect((await login(app, 'pw@example.test', 'neues-passwort-123')).res.statusCode).toBe(200); }); it('erlaubt Kunden, 2FA zu entfernen und mit neuem Gerät neu einzurichten', async () => { await makeUser({ email: 'phone@example.test' }); const { client } = await login(app, 'phone@example.test'); const s1 = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s1.secret) }); expect((await call(app, client, 'POST', '/auth/mfa/setup')).json().error.code).toBe('MFA_ALREADY_ENABLED'); // ohne korrektes Passwort / Code nicht möglich expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'falsch-falsch-falsch', code: nextCode(s1.secret) })).statusCode).toBe(403); expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: '000000' })).statusCode).toBe(403); expect((await call(app, client, 'POST', '/auth/mfa/disable', { password: 'correct-horse-battery', code: nextCode(s1.secret) })).statusCode).toBe(200); expect((await one('SELECT COUNT(*) n FROM mfa_totp m JOIN users u ON u.id = m.user_id WHERE u.email = ?', ['phone@example.test']))!.n).toBe(0); // Login ohne 2FA, danach neu einrichten mit neuem Secret const l2 = await login(app, 'phone@example.test'); expect(l2.res.json().status).toBe('ok'); const s2 = (await call(app, l2.client, 'POST', '/auth/mfa/setup')).json(); expect(s2.secret).not.toBe(s1.secret); expect((await call(app, l2.client, 'POST', '/auth/mfa/confirm', { code: code(s2.secret) })).statusCode).toBe(200); expect((await login(app, 'phone@example.test')).res.json().status).toBe('mfa_required'); }); it('erlaubt Support-Reset der 2FA für Kunden, für Mitarbeiter nur Superadmin', async () => { const { client: adm } = await staffWithMfa('resetadm@example.test', 'admin'); const cust = await makeUser({ email: 'lost@example.test' }); const cl = (await login(app, 'lost@example.test')).client; const st = (await call(app, cl, 'POST', '/auth/mfa/setup')).json(); await call(app, cl, 'POST', '/auth/mfa/confirm', { code: code(st.secret) }); expect((await call(app, adm, 'POST', `/admin/users/${cust}/mfa-reset`)).statusCode).toBe(200); expect((await call(app, cl, 'GET', '/auth/me')).statusCode).toBe(401); // Sitzungen beendet expect((await login(app, 'lost@example.test')).res.json().status).toBe('ok'); const staffId = await makeUser({ email: 'st@example.test', kind: 'staff', staffRole: 'support' }); expect((await call(app, adm, 'POST', `/admin/users/${staffId}/mfa-reset`)).json().error.code).toBe('PRIVILEGED_ONLY'); const { client: sa } = await staffWithMfa('resetsa@example.test', 'superadmin'); expect((await call(app, sa, 'POST', `/admin/users/${staffId}/mfa-reset`)).statusCode).toBe(200); expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='user.mfa.reset'"))!.n).toBe(2); }); }); describe('Privat- und Geschäftskunden', () => { it('erzwingt die Regeln je Kundenart und erlaubt Filter', async () => { const { client: adm } = await staffWithMfa('type-adm@example.test', 'admin'); const mk = (b: object) => call(app, adm, 'POST', '/admin/customers', b); // Typ ist Pflicht expect((await mk({ name: 'X', owner: { email: 'x1@example.test' } })).statusCode).toBe(400); // Privatkunde: keine Firma / USt-IdNr. expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { company: 'Firma GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY'); expect((await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p0@example.test' }, billing: { vatId: 'DE123456789' } })).json().error.code).toBe('PRIVATE_NO_COMPANY'); const priv = (await mk({ type: 'private', name: 'Erika Muster', owner: { email: 'p1@example.test' }, billing: { street: 'Weg 1', zip: '10115', city: 'Berlin' } })).json(); expect(priv.customerNumber).toMatch(/^K-/); // Ansprechpartner-Name = Kunde (Owner-Name entfällt) expect((await one("SELECT name FROM users WHERE email = 'p1@example.test'"))!.name).toBe('Erika Muster'); // Geschäftskunde: ungültige USt-IdNr. abgelehnt, gültige normalisiert; Firma = Name expect((await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b0@example.test', name: 'Max' }, billing: { vatId: '123' } })).json().error.code).toBe('INVALID_VAT_ID'); const biz = (await mk({ type: 'business', name: 'Muster GmbH', owner: { email: 'b1@example.test', name: 'Max Muster' }, billing: { vatId: 'de 123.456.789' } })).json(); const d = (await call(app, adm, 'GET', `/admin/customers/${biz.id}`)).json(); expect(d.customerType).toBe('business'); expect(d.billing.vatId).toBe('DE123456789'); expect(d.billing.company).toBe('Muster GmbH'); // Filter const onlyPriv = (await call(app, adm, 'GET', '/admin/customers?type=private')).json(); expect(onlyPriv.every((c: any) => c.customerType === 'private')).toBe(true); expect(onlyPriv.some((c: any) => c.id === priv.id)).toBe(true); // Wechsel Geschäft -> Privat nur, wenn Firma/USt-IdNr. entfernt werden expect((await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private' })).json().error.code).toBe('PRIVATE_NO_COMPANY'); const sw = await call(app, adm, 'PATCH', `/admin/customers/${biz.id}`, { customerType: 'private', billing: { company: '', vatId: '' } }); expect(sw.statusCode).toBe(200); expect(sw.json().customerType).toBe('private'); expect(sw.json().billing.vatId).toBeNull(); // Privatkunde kann keine Firma bekommen expect((await call(app, adm, 'PATCH', `/admin/customers/${priv.id}`, { billing: { company: 'Neu GmbH' } })).json().error.code).toBe('PRIVATE_NO_COMPANY'); }); });