kundencenter/apps/api/test/core.test.ts

219 lines
16 KiB
TypeScript
Raw Permalink Normal View History

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 Manipulation', 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/);
const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3');
await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]);
expect((await verifyAuditChain()).brokenAt).toBe(first!.id);
});
});
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');
});
});