kundencenter/apps/api/test/core.test.ts
Kundencenter 5ec8a526b3 fix(audit): audit_events auf DB-Ebene unveränderlich machen (append-only)
Nightbot-Befund #34 (Teil 1 von 2 – "eigene DB-Rolle für Audit ohne
UPDATE/DELETE"): eine echte separate DB-Rolle nur für Audit-Schreibzugriffe
ist mit dem bestehenden Muster (audit() schreibt oft in derselben
Transaktion wie die Fachaktion, die sie protokolliert, über dieselbe
Connection) nicht sauber vereinbar, ohne diese Atomarität zu verlieren
oder alle ~50 Tabellen einzeln neu zu berechtigen (MySQL/MariaDB kann
eine datenbankweite Berechtigung nicht durch eine engere Tabellen-
Berechtigung "überschreiben" – Rechte sind additiv, nicht spezifischer
gewinnt).

Stattdessen: BEFORE UPDATE/DELETE-Trigger auf audit_events, die beides
unabhängig vom verbindenden DB-Nutzer grundsätzlich verweigern (Migration
033). INSERT/SELECT bleiben uneingeschränkt möglich – genau das, was
audit()/verifyAuditChain() je brauchen. TRUNCATE bleibt technisch möglich
(feuert keine Trigger, betrifft nur die Testdatenbank-Zurücksetzung
zwischen Testdateien), DROP TABLE weiterhin auch – echte Kompromittierung
der DB-Zugangsdaten bleibt außerhalb dieser Verteidigungslinie, aber
versehentliche oder fehlerhafte Anwendungscode-Änderungen sind jetzt
ausgeschlossen.

test/core.test.ts angepasst: die bisherige Manipulationssimulation per
UPDATE ist jetzt selbst Teil des Tests (muss fehlschlagen); die Prüfung
"Hash-Kette erkennt Fälschung" simuliert stattdessen eine eingeschleuste
Fälschung per INSERT (weiterhin erlaubt).

Verifiziert: Testsuite (59/59, neuer Testfall für die Trigger-Ablehnung),
echte UPDATE/DELETE-Versuche gegen die Produktionsdatenbank beide
abgelehnt, Hash-Kette danach weiterhin intakt (192 Einträge), volles
Backup+Wiederherstellungstest gegen die echte Produktionsdatenbank
gefahren (Trigger werden korrekt mitgesichert/wiederhergestellt).

Aufbewahrungsfristen (DSGVO) und Export sind Teil desselben Tickets,
brauchen aber eine fachliche/rechtliche Entscheidung des Nutzers und
bleiben offen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:49:26 +02:00

227 lines
17 KiB
TypeScript

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');
});
});