Stand vor Einführung des Nacht-Agenten

This commit is contained in:
Kundencenter 2026-09-27 00:51:32 +02:00
commit 4763548bfb
168 changed files with 12726 additions and 0 deletions

View file

@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildApp } from '../src/server.js';
import { query } from '../src/core/db.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkapi-')); const statusFile = join(root, 'status.json'); const reqDir = join(root, 'requests');
beforeAll(async () => { process.env.BACKUP_STATUS_FILE = statusFile; process.env.BACKUP_REQUEST_DIR = reqDir; process.env.BACKUP_REMOTES = 'nas:kundencenter'; app = await buildApp(); await app.ready(); });
afterAll(() => { rmSync(root, { recursive: true, force: true }); delete process.env.BACKUP_STATUS_FILE; delete process.env.BACKUP_REQUEST_DIR; delete process.env.BACKUP_REMOTES; });
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
describe('Backup-Seite (API)', () => {
it('zeigt Zustand nur Berechtigten, ohne Statusdatei als "noch nie gelaufen"', async () => {
const admin = await staff('bk-adm@x.test', 'admin'); const sup = await staff('bk-sup@x.test', 'support');
expect((await call(app, sup, 'GET', '/admin/backup')).statusCode).toBe(403);
expect((await app.inject({ method: 'GET', url: '/v1/admin/backup' })).statusCode).toBe(401);
const s0 = (await call(app, admin, 'GET', '/admin/backup')).json();
expect(s0.lastRun).toBeNull(); expect(s0.stale).toBe(true); expect(s0.hasExternalTarget).toBe(true); expect(s0.remotes).toEqual(['nas:kundencenter']); expect(s0.retention).toEqual({ daily: 14, weekly: 8, monthly: 12 });
mkdirSync(join(root), { recursive: true });
writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date().toISOString(), ok: true, file: 'x.age', sizeBytes: 1000, durationMs: 900, targets: [{ name: 'lokal', ok: true }] }, lastRestoreTest: { at: new Date().toISOString(), ok: true, checks: { auditKette: true } }, history: [{ at: new Date().toISOString(), ok: true }] }));
const s1 = (await call(app, admin, 'GET', '/admin/backup')).json(); expect(s1.stale).toBe(false); expect(s1.restoreStale).toBe(false); expect(s1.history).toHaveLength(1);
// veraltet, wenn der letzte Erfolg zu alt ist
writeFileSync(statusFile, JSON.stringify({ lastRun: { at: new Date(Date.now() - 30 * 3600000).toISOString(), ok: true, targets: [] } }));
expect((await call(app, admin, 'GET', '/admin/backup')).json().stale).toBe(true);
});
it('legt Anfragen nur für feste Aktionen ab, nur einmal gleichzeitig, mit Audit', async () => {
const admin = await staff('bk-adm2@x.test', 'admin'); const sup = await staff('bk-sup2@x.test', 'support');
expect((await call(app, sup, 'POST', '/admin/backup/run', { action: 'backup' })).statusCode).toBe(403);
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'rm -rf /' })).statusCode).toBe(400); // nur feste Aktionen
expect(existsSync(reqDir) ? readdirSyncSafe(reqDir) : []).toEqual([]);
const ok = await call(app, admin, 'POST', '/admin/backup/run', { action: 'backup' }); expect(ok.statusCode).toBe(202);
expect(readdirSyncSafe(reqDir)).toEqual(['backup-run']);
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_PENDING'); // eine Anfrage nach der anderen
expect((await call(app, admin, 'GET', '/admin/backup')).json().pendingRequests).toEqual(['backup']);
unlinkSync(join(reqDir, 'backup-run'));
writeFileSync(statusFile, JSON.stringify({ running: { action: 'backup', since: new Date().toISOString() } }));
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).json().error.code).toBe('BACKUP_RUNNING');
writeFileSync(statusFile, JSON.stringify({}));
expect((await call(app, admin, 'POST', '/admin/backup/run', { action: 'restore-test' })).statusCode).toBe(202);
expect(readdirSyncSafe(reqDir)).toEqual(['backup-restore-test']);
expect((await query("SELECT action FROM audit_events WHERE action LIKE 'backup.request.%' ORDER BY id")).map((r) => r.action)).toEqual(['backup.request.backup', 'backup.request.restore-test']);
});
});
import { readdirSync } from 'node:fs';
function readdirSyncSafe(d: string): string[] { try { return readdirSync(d); } catch { return []; } }

View file

@ -0,0 +1,124 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildApp } from '../src/server.js';
import { one, query } from '../src/core/db.js';
import { parseName, fileName } from '../src/ops/retention.js';
import { passwordProblem, setBackupPassword } from '../src/ops/settings.js';
import { buildRemote, friendly, type TargetRow } from '../src/ops/targets.js';
import { loadBackupConfig, runBackup, runRestoreTest } from '../src/ops/backup.js';
import { config } from '../src/core/config.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance; const root = mkdtempSync(join(tmpdir(), 'kc-bkset-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const dest = join(root, 'ziel'); const status = join(root, 'status.json');
beforeAll(async () => { delete process.env.BACKUP_AGE_RECIPIENT; delete process.env.BACKUP_REMOTES; app = await buildApp(); await app.ready(); mkdirSync(envDir); mkdirSync(dest); writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); });
afterAll(() => rmSync(root, { recursive: true, force: true }));
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
describe('Backup-Passwort', () => {
it('verlangt mehr als 10 Zeichen', () => {
expect(passwordProblem('1234567890')).toMatch(/länger als 10/); // genau 10: zu kurz
expect(passwordProblem('12345678901')).toBeNull(); // 11: ok
expect(passwordProblem('aaaaaaaaaaaa')).toMatch(/wiederholt/);
expect(passwordProblem('abc\ndefghijkl')).toMatch(/Zeilenumbr/);
expect(passwordProblem('x'.repeat(201))).toMatch(/höchstens/);
});
it('setzt es nur mit Superadmin, Wiederholung und eigenem Anmeldepasswort; speichert es verschlüsselt', async () => {
const admin = await staff('bs-adm@x.test', 'admin'); const sa = await staff('bs-sa@x.test', 'superadmin');
const body = { password: 'Sehr-langes-Backup-Passwort-1', repeat: 'Sehr-langes-Backup-Passwort-1', currentPassword: 'correct-horse-battery' };
expect((await call(app, admin, 'PUT', '/admin/backup/password', body)).statusCode).toBe(403); // Admin darf nicht
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, currentPassword: 'falsch-falsch-falsch' })).json().error.code).toBe('INVALID_CREDENTIALS');
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, repeat: 'anderes-Passwort-12' })).json().error.code).toBe('PASSWORD_MISMATCH');
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: '1234567890', repeat: '1234567890' })).json().error.code).toBe('WEAK_PASSWORD');
const s0 = (await call(app, sa, 'GET', '/admin/backup/settings')).json(); expect(s0.encryption).toMatchObject({ mode: 'none', passwordSet: false, minLength: 11 });
expect((await call(app, sa, 'PUT', '/admin/backup/password', body)).json().version).toBe(1);
expect((await call(app, sa, 'PUT', '/admin/backup/password', { ...body, password: 'Noch-ein-anderes-Passwort-2', repeat: 'Noch-ein-anderes-Passwort-2' })).json().version).toBe(2);
const s1 = (await call(app, admin, 'GET', '/admin/backup/settings')).json(); expect(s1.encryption).toMatchObject({ mode: 'password', passwordSet: true, version: 2 });
expect(JSON.stringify(s1)).not.toContain('Passwort-2');
const row = await one("SELECT value_enc FROM backup_settings WHERE `key` = 'password'"); expect(row!.value_enc).toMatch(/^v1:/); expect(row!.value_enc).not.toContain('Passwort');
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toMatch(/Backup-Passwort-1|Passwort-2/); // nie im Audit
expect((await query("SELECT result FROM audit_events WHERE action = 'backup.password.change' ORDER BY id")).map((r) => r.result)).toEqual(['denied', 'success', 'success']);
});
});
describe('Externe Ziele', () => {
it('legt Ziele an, prüft Eingaben, liefert nie Geheimnisse und speichert sie verschlüsselt', async () => {
const sa = await staff('bt-sa@x.test', 'superadmin'); const admin = await staff('bt-adm@x.test', 'admin');
const ftp = { type: 'ftp', name: 'NAS-FTP', host: 'nas.example.test', user: 'backup', password: 'geheimes-ftp-passwort', tls: 'explicit', path: 'kundencenter' };
expect((await call(app, admin, 'POST', '/admin/backup/targets', ftp)).statusCode).toBe(403);
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: 'localhost' })).json().error.code).toBe('BAD_HOST'); // keine Zugriffe auf den Server selbst
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, host: '169.254.169.254' })).json().error.code).toBe('BAD_HOST');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { ...ftp, path: '../../etc' })).json().error.code).toBe('BAD_PATH');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Etc', path: '/etc/cron.d' })).json().error.code).toBe('BAD_PATH');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'sftp', name: 'S', host: 'sftp.example.test', user: 'u', authType: 'key', privateKey: 'kein schlüssel' })).json().error.code).toBe('SECRET_MISSING');
expect((await call(app, sa, 'POST', '/admin/backup/targets', { type: 'gdrive', name: 'G', token: 'kein-json-token-abc' })).json().error.code).toBe('BAD_TOKEN');
const ok = await call(app, sa, 'POST', '/admin/backup/targets', ftp); expect(ok.statusCode).toBe(200);
expect((await call(app, sa, 'POST', '/admin/backup/targets', ftp)).json().error.code).toBe('NAME_EXISTS');
const list = (await call(app, admin, 'GET', '/admin/backup/settings')).json().targets; // Admin sieht Ziele, aber keine Geheimnisse
expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ name: 'NAS-FTP', type: 'ftp', hasSecret: true, enabled: true });
expect(JSON.stringify(list)).not.toContain('geheimes-ftp'); expect(JSON.stringify(list)).not.toContain('secrets_enc');
const raw = await one('SELECT secrets_enc FROM backup_targets'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('geheimes');
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('geheimes-ftp');
// aktivieren/deaktivieren, löschen
const id = list[0].id; expect((await call(app, sa, 'PATCH', `/admin/backup/targets/${id}`, { enabled: false })).statusCode).toBe(200);
expect((await call(app, admin, 'GET', '/admin/backup')).json().hasExternalTarget).toBe(false); // deaktiviert zählt nicht
expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(200);
expect((await call(app, sa, 'DELETE', `/admin/backup/targets/${id}`)).statusCode).toBe(404);
});
it('testet Ziele mit echtem Schreibtest und meldet Fehler verständlich', async () => {
const sa = await staff('bt2-sa@x.test', 'superadmin');
const good = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Laufwerk', path: dest })).json().id;
const t1 = (await call(app, sa, 'POST', `/admin/backup/targets/${good}/test`)).json(); expect(t1.ok).toBe(true); expect(readdirSync(dest)).toEqual([]); // Testdatei wurde wieder gelöscht
const bad = (await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Fehlt', path: join(root, 'gibt-es-nicht') })).json().id;
const t2 = (await call(app, sa, 'POST', `/admin/backup/targets/${bad}/test`)).json(); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/existiert nicht/);
const s = (await call(app, sa, 'GET', '/admin/backup/settings')).json().targets; expect(s.find((x: any) => x.name === 'Laufwerk').lastTestOk).toBe(true); expect(s.find((x: any) => x.name === 'Fehlt').lastTestOk).toBe(false);
expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'backup.target.test'"))[0]!.n).toBe(2);
await call(app, sa, 'DELETE', `/admin/backup/targets/${bad}`);
});
it('baut rclone-Umgebung ohne Passwörter im Klartext und mit festem Server-Schlüssel', async () => {
const t: TargetRow = { id: '12345678-aaaa-bbbb-cccc-1234567890ab', name: 'x', type: 'sftp', path: 'kc', enabled: true, hostKey: 'sftp.example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc', config: { host: 'sftp.example.test', port: 2222, user: 'bk', authType: 'password' }, secrets: { password: 'klartext-passwort-9' } };
const r = await buildRemote(t);
const P = 'RCLONE_CONFIG_KCT12345678_'; expect(r.remote).toBe('KCT12345678:kc'); expect(r.env[`${P}TYPE`]).toBe('sftp'); expect(r.env[`${P}PORT`]).toBe('2222');
expect(r.env[`${P}PASS`]).toBeTruthy(); expect(r.env[`${P}PASS`]).not.toContain('klartext'); // obscured
const kh = r.env[`${P}KNOWN_HOSTS_FILE`]!; expect(readFileSync(kh, 'utf8')).toContain('ssh-ed25519'); expect(r.env.RCLONE_CONFIG).toBeTruthy(); await r.cleanup(); expect(existsSync(kh)).toBe(false); expect(existsSync(r.env.RCLONE_CONFIG!)).toBe(false);
expect(friendly(new Error('ssh: handshake failed: knownhosts: key mismatch'))).toMatch(/Server-Schlüssel/);
expect(friendly(new Error('Permission denied (publickey,password)'))).toMatch(/Anmeldung/);
expect(friendly(new Error('dial tcp: lookup nas.x: no such host'))).toMatch(/nicht gefunden/);
});
});
describe('Backup mit Passwort und Ziel aus der Oberfläche', () => {
it('verschlüsselt mit gpg, lädt zum definierten Ziel, Restore-Test besteht; falsches/geändertes Passwort wird erkannt', async () => {
const sa = await staff('bp-sa@x.test', 'superadmin');
await query('DELETE FROM backup_targets'); // Ziele früherer Tests entfernen
await setBackupPassword('Mein-Backup-Passwort-2026', null);
await call(app, sa, 'POST', '/admin/backup/targets', { type: 'local', name: 'Ziel-Laufwerk', path: dest });
const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true);
expect(r.file).toMatch(/\.tar\.gz\.gpg$/); expect(parseName(r.file!)?.ext).toBe('gpg'); expect(fileName(new Date(), 'gpg')).toMatch(/\.gpg$/);
expect(r.targets.map((t) => [t.name.startsWith('lokal') ? 'lokal' : t.name, t.ok])).toEqual([['lokal', true], ['Ziel-Laufwerk', true]]);
expect(readdirSync(dest).sort()).toEqual([r.file, `${r.file}.sha256`].sort());
// Ohne Passwort unlesbar, mit Standard-gpg lesbar (Notfall-Weg ohne unser Tool)
const file = join(dest, r.file!); const home = mkdtempSync(join(tmpdir(), 'gh-'));
const dec = (pw: string) => execFileSync('gpg', ['--homedir', home, '--batch', '--pinentry-mode', 'loopback', '--passphrase-fd', '0', '-d', file], { input: pw, stdio: ['pipe', 'pipe', 'pipe'] });
expect(() => dec('falsches-Passwort-123')).toThrow(); expect(dec('Mein-Backup-Passwort-2026').length).toBeGreaterThan(500);
const raw = readFileSync(file); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false);
const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true); expect(t.checks).toMatchObject({ auditKette: true, geheimnisseOk: true });
// Passwort ändern: alte Sicherung ist mit dem neuen Passwort nicht mehr lesbar (klare Meldung), neue Sicherung besteht
await setBackupPassword('Ganz-Neues-Passwort-2027', null);
const old = await runRestoreTest(cfg(), r.file); expect(old.ok).toBe(false); expect(old.error).toMatch(/Passwort passt nicht/);
const r2 = await runBackup(cfg(), new Date(Date.now() + 120000)); expect(r2.ok).toBe(true);
expect((await runRestoreTest(cfg(), r2.file)).ok).toBe(true);
});
it('sichert nie unverschlüsselt: ohne Passwort und Schlüssel bricht der Lauf ab', async () => {
await query("DELETE FROM backup_settings");
const r = await runBackup(cfg(), new Date(Date.now() + 240000)); expect(r.ok).toBe(false); expect(r.error).toMatch(/Keine Verschlüsselung/);
});
});

View file

@ -0,0 +1,87 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { statSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, rmSync, appendFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { encrypt } from '../src/core/crypto.js';
import { audit } from '../src/core/audit.js';
import { run as dbRun } from '../src/core/db.js';
import { config } from '../src/core/config.js';
import { labelFor, parseName, selectDeletions, fileName } from '../src/ops/retention.js';
import { loadBackupConfig, runBackup, runRestoreTest, readStatus, latestLocal } from '../src/ops/backup.js';
const d = (s: string) => new Date(`${s}T03:00:00Z`);
describe('Aufbewahrung', () => {
it('benennt und erkennt Sicherungen', () => {
expect(fileName(new Date('2026-09-26T02:30:05Z'))).toBe('kundencenter-20260926-023005-daily.tar.gz.age');
expect(labelFor(d('2026-09-27'))).toBe('weekly'); // Sonntag
expect(labelFor(d('2026-10-01'))).toBe('monthly'); // Monatserster hat Vorrang vor Wochentag
expect(labelFor(d('2026-09-30'))).toBe('daily');
expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age')?.label).toBe('daily');
expect(parseName('fremd.txt')).toBeNull();
expect(parseName('kundencenter-20260926-023005-daily.tar.gz.age.sha256')).toBeNull();
});
it('löscht nach Alter je Kategorie, nie Fremdes, nie unter den Mindestbestand', () => {
const mk = (s: string) => fileName(d(s)); const now = d('2026-09-30'); const keep = { daily: 14, weekly: 8, monthly: 12 };
const names = [mk('2026-09-29'), mk('2026-09-10'), mk('2026-08-02'), mk('2026-08-01'), mk('2025-08-01'), mk('2025-10-01'), 'notizen.txt'];
const del = selectDeletions(names, now, keep);
expect(del).toContain(mk('2026-09-10')); // daily, 20 Tage
expect(del).toContain(mk('2025-08-01')); // monthly, älter als 12 Monate
expect(del).not.toContain(mk('2026-09-29')); expect(del).not.toContain(mk('2026-08-01')); expect(del).not.toContain(mk('2025-10-01')); expect(del).not.toContain('notizen.txt');
// Mindestbestand: auch wenn alles alt ist, bleiben die 3 neuesten
const old = ['2024-01-05', '2024-01-06', '2024-01-08', '2024-01-09'].map(mk);
expect(selectDeletions(old, now, keep)).toEqual([old[0]]);
expect(selectDeletions([], now, keep)).toEqual([]);
});
});
describe('Backup und Wiederherstellungstest (echte Werkzeuge, Testdatenbank)', () => {
const root = mkdtempSync(join(tmpdir(), 'kc-bk-')); const envDir = join(root, 'etc'); const dir = join(root, 'backups'); const remote = join(root, 'remote'); const idFile = join(root, 'age-key.txt'); const status = join(root, 'state', 'status.json');
let recipient = '';
beforeAll(async () => {
mkdirSync(envDir); mkdirSync(remote);
writeFileSync(join(envDir, 'app.env'), `KC_SECRET_KEY=${config.secretKey.toString('base64')}\n`); writeFileSync(join(envDir, 'db.env'), 'DB_NAME=x\n');
execFileSync('age-keygen', ['-o', idFile], { stdio: 'pipe' }); recipient = /public key: (age1\w+)/.exec(readFileSync(idFile, 'utf8'))![1]!;
// echte Beispieldaten inkl. verschlüsselter Geheimnisse und Audit-Kette
await dbRun("INSERT INTO connector_instances (id, connector_key, name, config_json, secrets_enc) VALUES (UUID(), 'mock', 'bk-test', '{}', ?)", [encrypt(JSON.stringify({ token: 'geheim' }))]);
await audit({ actorType: 'system', action: 'test.eins' }); await audit({ actorType: 'system', action: 'test.zwei' });
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
const cfg = () => loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: idFile, BACKUP_REMOTES: remote, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
it('erstellt ein verschlüsseltes Backup, lädt es zum Ziel und der Wiederherstellungstest besteht', async () => {
const r = await runBackup(cfg()); expect(r.error).toBeUndefined(); expect(r.ok).toBe(true);
const local = readdirSync(dir).filter((n) => parseName(n)); expect(local).toHaveLength(1);
expect(statSync(join(dir, local[0]!)).mode & 0o077).toBe(0); // keine Rechte für Gruppe/Andere
expect(existsSync(join(dir, `${local[0]}.sha256`))).toBe(true); expect(readdirSync(remote).sort()).toEqual([local[0], `${local[0]}.sha256`].sort());
// Klartext darf nirgends liegen: weder SQL noch Schlüssel in der Datei
const raw = readFileSync(join(dir, local[0]!)); expect(raw.includes(Buffer.from('CREATE TABLE'))).toBe(false); expect(raw.includes(Buffer.from(config.secretKey.toString('base64')))).toBe(false);
expect(raw.subarray(0, 21).toString()).toContain('age-encryption.org');
expect(readdirSync(dir).some((n) => n.startsWith('.work-'))).toBe(false); // Arbeitsordner aufgeräumt
const t = await runRestoreTest(cfg()); expect(t.error).toBeUndefined(); expect(t.ok).toBe(true);
expect(t.checks).toMatchObject({ pruefsumme: true, dumpPruefsumme: true, zeilenzahlen: true, migrationen: true, auditKette: true, geheimnisseOk: true }); expect(Number(t.checks.geheimnisseGeprueft)).toBeGreaterThanOrEqual(1);
const s = await readStatus(cfg()); expect(s.lastRun?.ok).toBe(true); expect(s.lastRestoreTest?.ok).toBe(true);
expect(await latestLocal(cfg())).toBe(local[0]);
});
it('meldet einen defekten Ziel-Ablauf als Fehler, behält aber das lokale Backup', async () => {
const bad = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_REMOTES: '/proc/nicht/schreibbar', BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
const r = await runBackup(bad, new Date(Date.now() + 60000)); expect(r.ok).toBe(false);
expect(r.targets.find((x) => x.name.startsWith('/proc'))?.ok).toBe(false); expect(r.targets[0]!.ok).toBe(true);
expect((await readStatus(cfg())).lastRun?.ok).toBe(false);
// ohne Passwort und ohne age-Schlüssel wird nichts unverschlüsselt gesichert
const none = await runBackup(loadBackupConfig({ BACKUP_DIR: dir, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH }), new Date(Date.now() + 120000));
expect(none.ok).toBe(false); expect(none.error).toMatch(/Keine Verschlüsselung/);
});
it('erkennt manipulierte Sicherungen und fehlende Schlüssel', async () => {
const name = (await latestLocal(cfg()))!; const file = join(dir, name);
const before = readFileSync(file); appendFileSync(file, 'x');
const t1 = await runRestoreTest(cfg(), name); expect(t1.ok).toBe(false); expect(t1.error).toMatch(/Prüfsumme/);
writeFileSync(file, before);
const other = join(root, 'other.txt'); execFileSync('age-keygen', ['-o', other], { stdio: 'pipe' });
const wrong = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_AGE_IDENTITY: other, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
const t2 = await runRestoreTest(wrong, name); expect(t2.ok).toBe(false); expect(t2.error).toMatch(/age/);
const none = loadBackupConfig({ BACKUP_DIR: dir, BACKUP_AGE_RECIPIENT: recipient, BACKUP_STATUS_FILE: status, KC_BACKUP_ENV_DIR: envDir, PATH: process.env.PATH });
expect((await runRestoreTest(none, name)).error).toMatch(/Schlüssel/);
expect((await runRestoreTest(cfg(), name)).ok).toBe(true); // Original wieder heil -> Test besteht
});
});

View file

@ -0,0 +1,120 @@
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 { syncInstance } from '@kc/connectors';
import { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) });
return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
const drain = async () => { for (let i = 0; i < 20 && (await runOnce(() => undefined)); i++); };
describe('Connectoren, Ressourcen und Aufträge', () => {
it('Ende-zu-Ende: Verbindung, Abgleich, Zuweisung, Rechte, idempotenter Auftrag, Ausfall mit letztem Stand', async () => {
resetMock();
const admin = await staff('adm@conn.test', 'admin'); const sup = await staff('sa@conn.test', 'superadmin');
const A = await customer(admin, 'Firma A', 'a@conn.test'); const B = await customer(admin, 'Firma B', 'b@conn.test');
// Nur Superadmin darf Verbindungen (mit Geheimnissen) anlegen
const body = { connector: 'mock', name: 'Mock-Test', values: { instance: 'e2e' } };
expect((await call(app, admin, 'POST', '/admin/connectors', body)).statusCode).toBe(403);
const created = (await call(app, sup, 'POST', '/admin/connectors', body)).json();
const list = (await call(app, sup, 'GET', '/admin/connectors')).json();
expect(list[0].hasSecrets).toBe(false); expect(JSON.stringify(list)).not.toMatch(/secrets_enc|password/);
// Abgleich über Worker-Job (angelegt beim Erstellen)
await drain();
const inst = (await call(app, sup, 'GET', '/admin/connectors')).json()[0];
expect(inst.health).toBe('ok'); expect(inst.resources).toBe(3);
const all = (await call(app, admin, 'GET', '/resources')).json();
expect(all).toHaveLength(3);
// Nicht zugewiesene Ressourcen sind für Kunden unsichtbar
expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(0);
const res = all.find((r: any) => r.name.includes('Pro'));
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404);
// Zuweisung an A: A sieht sie, B nicht
expect((await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org })).statusCode).toBe(200);
expect((await call(app, A.client, 'GET', '/resources')).json()).toHaveLength(1);
expect((await call(app, B.client, 'GET', `/resources/${res.id}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).statusCode).toBe(404);
// Aktionen: standardmäßig keine für Kunden (Ressourcenregel), Staff sieht alle unterstützten
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual([]);
expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'suspend' })).json().error.code).toBe('ACTION_NOT_ALLOWED');
expect((await call(app, admin, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend', 'extend']);
await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { customerActions: ['suspend', 'unsuspend'] });
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().allowedActions).toEqual(['suspend', 'unsuspend']);
expect((await call(app, A.client, 'POST', `/resources/${res.id}/actions`, { action: 'extend', days: 30 })).statusCode).toBe(403);
// Idempotenz: gleicher Schlüssel => ein Auftrag
const hdr = { cookie: A.client.cookie, 'x-csrf-token': A.client.csrf, 'idempotency-key': 'klick-0001-abcdef' };
const r1 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr });
const r2 = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/actions`, payload: { action: 'suspend' }, headers: hdr });
expect(r1.statusCode).toBe(202); expect(r2.json().jobId).toBe(r1.json().jobId); expect(r2.json().duplicate).toBe(true);
expect((await query("SELECT id FROM jobs WHERE type='connector.execute'")).length).toBe(1);
expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('scheduled');
expect((await call(app, B.client, 'GET', `/jobs/${r1.json().jobId}`)).statusCode).toBe(404);
// Worker führt aus; Zustand + Audit
await drain();
expect((await call(app, A.client, 'GET', `/jobs/${r1.json().jobId}`)).json().status).toBe('succeeded');
expect((await call(app, A.client, 'GET', `/resources/${res.id}`)).json().state).toBe('suspended');
const au = await one("SELECT actor_id, connector, result FROM audit_events WHERE action='resource.suspend' ORDER BY id DESC LIMIT 1");
expect(au!.connector).toBe('mock'); expect(au!.result).toBe('success');
// Providerausfall: letzter Stand bleibt sichtbar, mit Hinweis
await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'true' } });
await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain();
const down = (await call(app, sup, 'GET', '/admin/connectors')).json()[0];
expect(down.health).toBe('down'); expect(down.lastOkAt).toBeTruthy();
const stale = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json();
expect(stale.stale).toBe(true); expect(stale.state).toBe('suspended'); expect(stale.staleReason).toMatch(/nicht erreichbar/);
expect(stale.allowedActions).toEqual([]); // keine Aktionen während des Ausfalls
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.down'"))!.n).toBe(1);
// Aktion bei Ausfall: wird wiederholt (retrying), nicht verloren – nach Erholung erfolgreich
await call(app, sup, 'PATCH', `/admin/connectors/${created.id}`, { values: { simulateOutage: 'false' } });
await call(app, sup, 'POST', `/admin/connectors/${created.id}/sync`); await drain();
expect((await call(app, sup, 'GET', '/admin/connectors')).json()[0].health).toBe('ok');
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action='connector.recovered'"))!.n).toBe(1);
});
it('wiederholt fehlgeschlagene Aufträge begrenzt, destruktive nie', async () => {
const inst = await one("SELECT id FROM connector_instances LIMIT 1");
await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]);
const res = await one('SELECT id FROM resources LIMIT 1');
const mk = async (payload: object, max = 2) => { const id = crypto.randomUUID(); await run('INSERT INTO jobs (id,type,payload,max_attempts) VALUES (?,?,?,?)', [id, 'connector.execute', JSON.stringify({ resourceId: res!.id, actorUserId: null, ...payload }), max]); return id; };
const retry = await mk({ action: 'suspend' });
await run("UPDATE jobs SET status='succeeded' WHERE type='connector.execute' AND id <> ?", [retry]);
await runOnce(() => undefined);
expect((await one('SELECT status, attempts, last_error FROM jobs WHERE id = ?', [retry]))).toMatchObject({ status: 'retrying', attempts: 1 });
await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE id = ?", [retry]);
await runOnce(() => undefined);
expect((await one('SELECT status FROM jobs WHERE id = ?', [retry]))!.status).toBe('needs_review'); // Limit erreicht -> manuelle Prüfung
const term = await mk({ action: 'terminate', destructive: true }, 5);
await runOnce(() => undefined);
const t = await one('SELECT status, attempts FROM jobs WHERE id = ?', [term]);
expect(t).toMatchObject({ status: 'needs_review', attempts: 1 }); // nicht automatisch wiederholt
// Manuelle Wiederholung: normaler Auftrag ja, destruktiver nein
const adm = await staff('adm2@conn.test', 'admin');
expect((await call(app, adm, 'POST', `/admin/jobs/${retry}/retry`)).statusCode).toBe(200);
expect((await call(app, adm, 'POST', `/admin/jobs/${term}/retry`)).json().error.code).toBe('DESTRUCTIVE');
});
});

218
apps/api/test/core.test.ts Normal file
View file

@ -0,0 +1,218 @@
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');
});
});

View file

@ -0,0 +1,98 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { breakdown, normalizeDomain, parsePriceList, roundPrice, sellPrice, splitDomain } from '../src/modules/domains/logic.js';
import { call, code, login, makeUser } from './helpers.js';
const PASTE = `Domaintyp\tLaufzeit\t0-49\t50-199\t200-999\t1000+\tSetup
com\t12\t12,50\t12,00\t11,00\t10,50\t
com.au\t24\t69,00\t69,00\t68,00\t68,00\t
cy\t12\t69,00\t69,00\t69,00\t69,00\t75,00 €
voting\t12\t1.380,00\t1.360,00\t1.340,00\t1.320,00\t
de\t12\t4,50\t4,00\t3,50\t3,00\t
Laufzeit in Monaten, alle Preise zzgl. Ust.`;
describe('Domain-Logik', () => {
it('liest das eingefügte Preislistenformat (deutsche Zahlen, Setup, Kopf/Fuß)', () => {
const { rows, skipped } = parsePriceList(PASTE);
expect(rows.map((r) => r.tld)).toEqual(['com', 'com.au', 'cy', 'voting', 'de']);
expect(rows[1]).toMatchObject({ termMonths: 24, costs: [6900, 6900, 6800, 6800] });
expect(rows[2]!.setupCents).toBe(7500); expect(rows[3]!.costs[0]).toBe(138000); expect(rows[0]!.setupCents).toBe(0);
expect(skipped.length).toBe(1);
});
it('Verkaufspreis = Einkauf + Aufschlag; ohne Aufschlag kein Preis', () => {
const none = { type: null, value: null };
expect(sellPrice(1250, none, none)).toBeNull();
expect(sellPrice(1250, none, { type: 'percent', value: 2500 })).toBe(1563);
expect(sellPrice(1250, none, { type: 'fixed', value: 300 })).toBe(1550);
expect(sellPrice(1250, { type: 'fixed', value: 100 }, { type: 'percent', value: 2500 })).toBe(1350); // Endungs-Aufschlag hat Vorrang
expect(sellPrice(400, { type: 'percent', value: 0 }, none)).toBe(400);
});
it('Liste brutto: Netto wird herausgerechnet, keine zweite USt; Liste netto: USt wird aufgeschlagen', () => {
const m = { type: 'percent' as const, value: 2000 }, none = { type: null, value: null };
expect(breakdown(1190, none, m, false, 'gross', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 });
expect(breakdown(1000, none, m, false, 'net', 1900)).toEqual({ costGrossCents: 1190, costNetCents: 1000, priceGrossCents: 1428, priceNetCents: 1200, profitNetCents: 200 });
});
it('rundet auf ,50 bzw. ,99', () => {
expect([1200, 1230, 1250, 1251, 1298, 1299, 450, 475, 1500].map(roundPrice)).toEqual([1250, 1250, 1250, 1299, 1299, 1299, 450, 499, 1550]);
expect(sellPrice(1250, { type: null, value: null }, { type: 'percent', value: 2000 }, true)).toBe(1550); // 15,00 → 15,50
});
it('normalisiert und zerlegt Domainnamen', () => {
expect(normalizeDomain(' https://www.Beispiel.DE/pfad?x=1 ')).toBe('beispiel.de');
expect(normalizeDomain('müller.de')).toBe('xn--mller-kva.de');
expect(normalizeDomain('a b.de')).toBeNull(); expect(normalizeDomain('-x.de')).toBeNull();
const known = new Set(['uk', 'co.uk', 'de']);
expect(splitDomain('firma.co.uk', known)).toEqual({ label: 'firma', tld: 'co.uk' });
expect(splitDomain('firma.de', known)).toEqual({ label: 'firma', tld: 'de' });
});
});
let app: FastifyInstance; const realFetch = globalThis.fetch;
beforeAll(async () => {
vi.stubGlobal('fetch', (async (url: any, init: any) => {
const u = String(url);
if (u.includes('data.iana.org')) return new Response(JSON.stringify({ services: [[['de', 'com', 'net'], ['https://rdap.test/']]] }), { status: 200 });
if (u.startsWith('https://rdap.test/domain/')) return new Response('{}', { status: u.includes('vergeben') ? 200 : 404 });
return realFetch(url, init);
}) as typeof fetch);
app = await buildApp(); await app.ready();
});
afterAll(() => vi.unstubAllGlobals());
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
describe('Domains: Preisliste, Aufschlag, Prüfung', () => {
it('Import, Aufschlag, Verkaufspreis, Verfügbarkeit; Kunden sehen keinen Einkauf', async () => {
const admin = await staff('dom-admin@example.com', 'admin'); const sup = await staff('dom-support@example.com', 'support');
const imp = await call(app, admin, 'POST', '/admin/domain-tlds/import', { text: PASTE }); expect(imp.statusCode).toBe(200); expect(imp.json()).toMatchObject({ rows: 5, created: 5 });
expect((await call(app, sup, 'POST', '/admin/domain-tlds/import', { text: PASTE })).statusCode).toBe(403);
expect((await call(app, sup, 'GET', '/admin/domain-tlds')).statusCode).toBe(200);
const before = (await call(app, admin, 'GET', '/domains/check?name=frei.com')).json().results[0];
expect(before).toMatchObject({ domain: 'frei.com', status: 'available', method: 'rdap', offered: true, offer: null }); // noch kein Aufschlag → kein Preis
expect((await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: false, margin: { type: 'percent', value: 2000 } })).statusCode).toBe(200);
expect((await call(app, admin, 'PATCH', '/admin/domain-tlds/de', { margin: { type: 'fixed', value: 500 } })).statusCode).toBe(200);
const list = (await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds; const com = list.find((t: any) => t.tld === 'com'); const de = list.find((t: any) => t.tld === 'de');
expect(com).toMatchObject({ costGrossCents: 1250, costNetCents: 1050, priceGrossCents: 1500, priceNetCents: 1261, profitNetCents: 211 }); expect(de).toMatchObject({ costGrossCents: 450, priceGrossCents: 950, priceNetCents: 798 });
await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, basis: 'gross', rounding: true, margin: { type: 'percent', value: 2000 } });
expect((await call(app, admin, 'GET', '/admin/domain-tlds')).json().tlds.find((t: any) => t.tld === 'com')).toMatchObject({ priceGrossCents: 1550, priceNetCents: 1303, profitNetCents: 253 });
await makeUser({ email: 'dom-kunde@example.com' }); const { client: cust } = await login(app, 'dom-kunde@example.com');
const r = (await call(app, cust, 'GET', '/domains/check?name=vergeben.com')).json().results[0];
expect(r).toMatchObject({ status: 'registered', offer: { priceGrossCents: 1550, priceNetCents: 1303, termMonths: 12 } }); expect(JSON.stringify(r)).not.toContain('cost');
const multi = (await call(app, cust, 'GET', '/domains/check?name=firma')).json().results; expect(multi.map((x: any) => x.domain).sort()).toEqual(['firma.com', 'firma.de']);
expect((await call(app, cust, 'GET', '/domains/check?name=a%20b.de')).statusCode).toBe(400);
expect((await call(app, cust, 'GET', '/admin/domain-tlds')).statusCode).toBe(403);
// Aufstellung: Einkauf netto, Verkauf netto/brutto (19 %), Beschaffungsstatus
const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200);
expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400);
const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' });
expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200);
expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' });
expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403);
expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter
await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false });
expect((await call(app, cust, 'GET', '/domains/check?name=frei.com')).json().results[0]).toMatchObject({ offered: false, offer: null });
});
});

View file

@ -0,0 +1,21 @@
import mysql from 'mysql2/promise';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import '../src/core/config.js';
/** Legt eine frische Testdatenbank an (niemals die produktive) und wendet alle Migrationen an. */
export default async function setup() {
const base = { host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD! };
const admin = await mysql.createConnection(base);
await admin.query('DROP DATABASE IF EXISTS kundencenter_test');
await admin.query('CREATE DATABASE kundencenter_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
await admin.end();
const c = await mysql.createConnection({ ...base, database: 'kundencenter_test' });
await c.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))');
const dir = join(process.cwd(), '../../migrations');
for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) {
await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]);
for (const st of readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)) await c.query(st);
}
await c.end();
}

23
apps/api/test/helpers.ts Normal file
View file

@ -0,0 +1,23 @@
import type { FastifyInstance } from 'fastify';
import { randomUUID } from 'node:crypto';
import { hash } from '@node-rs/argon2';
import * as OTPAuth from 'otpauth';
import { run } from '../src/core/db.js';
export interface Client { cookie: string; csrf: string }
export async function makeUser(o: { email: string; kind?: 'customer' | 'staff'; staffRole?: string; password?: string }) {
const id = randomUUID();
await run("INSERT INTO users (id,email,name,password_hash,kind,staff_role,status) VALUES (?,?,?,?,?,?, 'active')", [id, o.email, o.email, await hash(o.password ?? 'correct-horse-battery'), o.kind ?? 'customer', o.staffRole ?? null]);
return id;
}
export async function login(app: FastifyInstance, email: string, password = 'correct-horse-battery'): Promise<{ res: any; client: Client }> {
const res = await app.inject({ method: 'POST', url: '/v1/auth/login', payload: { email, password } });
const cookie = (res.cookies[0] ? `${res.cookies[0].name}=${res.cookies[0].value}` : '');
const body = res.json();
return { res, client: { cookie, csrf: body.csrf } };
}
export const call = (app: FastifyInstance, c: Client, method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', url: string, payload?: unknown) =>
app.inject({ method, url: `/v1${url}`, payload: payload as never, headers: { cookie: c.cookie, 'x-csrf-token': c.csrf } });
export const code = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate();
/** Nächster gültiger Code (für zweiten Schritt innerhalb desselben Tests, da Wiederverwendung verboten ist). */
export const nextCode = (secret: string) => new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32(secret), digits: 6, period: 30 }).generate({ timestamp: Date.now() + 30000 });

View file

@ -0,0 +1,122 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, query, run } from '../src/core/db.js';
import { runOnce } from '../../worker/src/jobs.js';
import { createFake, type Fake } from '../../../packages/connector-keyhelp/test/fake.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance; let fake: Fake; const realFetch = globalThis.fetch;
beforeAll(async () => {
fake = createFake('kh-test-key');
vi.stubGlobal('fetch', ((url: any, init: any) => (String(url).startsWith('https://kh.test') ? fake.fetch(url, init) : realFetch(url, init))) as typeof fetch);
app = await buildApp(); await app.ready();
});
afterAll(() => vi.unstubAllGlobals());
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
const accept = async (link: string, pw: string) => app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(link).searchParams.get('token'), password: pw, repeat: pw } });
describe('KeyHelp: Verbindung, Kundenübernahme, neue Tarife, Hosting-Funktionen', () => {
it('Ende-zu-Ende gegen einen nach der Definition nachgebauten KeyHelp-Server', async () => {
const admin = await staff('kh-adm@x.test', 'admin'); const sa = await staff('kh-sa@x.test', 'superadmin'); const sup = await staff('kh-sup@x.test', 'support');
// ---- Verbindung: nur Superadmin, Geheimnis verschlüsselt, Abgleich liest Konten mit Nutzung
const types = (await call(app, sa, 'GET', '/admin/connector-types')).json(); expect(types.find((t: any) => t.key === 'keyhelp').fields.map((f: any) => f.name)).toEqual(['baseUrl', 'apiKey', 'verifyTls', 'tlsFingerprint']);
const conn = await call(app, sa, 'POST', '/admin/connectors', { connector: 'keyhelp', name: 'KeyHelp-Test', values: { baseUrl: 'https://kh.test', apiKey: 'kh-test-key' } }); expect(conn.statusCode).toBe(200);
const raw = await one('SELECT secrets_enc FROM connector_instances'); expect(raw!.secrets_enc).toMatch(/^v1:/); expect(raw!.secrets_enc).not.toContain('kh-test-key');
await drain();
const inst = (await call(app, sa, 'GET', '/admin/connectors')).json()[0]; expect(inst.health).toBe('ok'); expect(inst.resources).toBe(2);
expect(inst.capabilities).toEqual(expect.arrayContaining(['customers.list', 'catalog.write', 'children.read', 'children.write', 'sso.login', 'lifecycle.create', 'plan.change']));
const res = (await call(app, admin, 'GET', '/resources')).json(); expect(res.map((r: any) => r.state).sort()).toEqual(['active', 'suspended']);
const alphaRes = res.find((r: any) => r.name.includes('alpha')); const det = (await call(app, admin, 'GET', `/resources/${alphaRes.id}`)).json();
expect(det.usage).toMatchObject({ domains: 2 }); expect(det.limits).toMatchObject({ disk_space: 10737418240 }); expect(det.details.plan).toBe('Starter');
// ---- neuen Tarif anlegen + Produkt definieren
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const planBody = { plan: { name: 'Neu Web 20', limits: { diskSpaceGb: 20, trafficGb: 200, domains: 3, emailAccounts: 10, databases: 3, ftpUsers: 2 }, permissions: { ftp: true, ssh: false } },
product: { sku: 'HOST-NEU-20', status: 'draft', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'email.manage'], version: { name: 'Webhosting Neu 20', taxRateId: t19, priceBasis: 'gross', recurringCents: 499, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30 } } };
expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody)).statusCode).toBe(403);
const created = await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, planBody); expect(created.statusCode).toBe(200);
expect(created.json().plan.features).toEqual(expect.arrayContaining(['Speicher: 20 GB', 'Domains: 3', 'FTP']));
const newPlan = fake.state.plans.find((p) => p.name === 'Neu Web 20')!; expect(newPlan.resources.disk_space).toBe(20 * 1024 ** 3);
const prod = (await call(app, admin, 'GET', `/admin/products/${created.json().productId}`)).json(); expect(prod).toMatchObject({ sku: 'HOST-NEU-20', status: 'draft', externalRef: `plan:${newPlan.id}`, provisioning: { hostingPlanId: newPlan.id } });
expect((await call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/hosting-plans`, { ...planBody, product: { ...planBody.product, sku: 'HOST-NEU-21' } })).json().error.code).toBe('PLAN_EXISTS'); // Name schon vergeben
// ---- Produkt zum vorhandenen Tarif "Starter" (Übernahme aus Katalog) für die Bestandsverträge
const starter = (await call(app, admin, 'POST', '/admin/products', { sku: 'HOST-STARTER', category: 'hosting', connectorInstanceId: conn.json().id, externalRef: 'plan:1', status: 'active', orderableByCustomer: false, requiresApproval: true, customerActions: ['panel.login', 'domain.manage', 'email.manage', 'suspend'],
version: { name: 'Starter', taxRateId: t19, priceBasis: 'gross', recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: { hostingPlanId: 1, language: 'de', createSystemDomain: true, sendLoginCredentials: true } } })).json();
// ---- Kunden aus KeyHelp lesen (Kontaktdaten), Rechte
fake.state.clients[0]!.contact_data = { company: 'Alpha GmbH', first_name: 'Anna', last_name: 'Alpha', telephone: '0123', address: 'Weg 1', zip: '10115', city: 'Berlin', country: 'DE', client_id: 'K-77' };
expect((await call(app, sup, 'GET', `/admin/connectors/${conn.json().id}/customers`)).statusCode).toBe(403);
const cust = (await call(app, admin, 'GET', `/admin/connectors/${conn.json().id}/customers`)).json();
expect(cust.map((c: any) => c.externalRef)).toEqual(['1', '2']); expect(cust[0]).toMatchObject({ displayName: 'Alpha GmbH', imported: null, resourceKnown: true, planRef: 'plan:1' }); expect(cust[0].suggestedProducts.map((p: any) => p.sku)).toEqual(['HOST-STARTER']);
expect(JSON.stringify(cust)).not.toMatch(/GEHEIM|password/i);
// ---- Übernahme: Kunde, Anschrift, Inhaber (eingeladen, KEINE Mail), Konto zugeordnet, Bestandsvertrag
const imp = (items: any[]) => call(app, admin, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items });
expect((await call(app, sup, 'POST', `/admin/connectors/${conn.json().id}/customers/import`, { items: [{ externalRef: '1', type: 'business' }] })).statusCode).toBe(403);
const r1 = (await imp([{ externalRef: '1', type: 'business', contract: { productId: starter.id, startedAt: '2025-01-15T00:00:00Z' } }, { externalRef: '2', type: 'private', name: 'Bernd Beta' }])).json().results;
expect(r1.map((r: any) => r.status)).toEqual(['created', 'created']); expect(r1[0].contractNumber).toMatch(/^V-/); expect(r1[1].contractNumber).toBeUndefined();
const orgA = await one("SELECT o.*, b.company, b.street, b.zip, b.city, b.billing_email FROM organizations o JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?", [r1[0].orgId]);
expect(orgA).toMatchObject({ name: 'Alpha GmbH', customer_type: 'business', company: 'Alpha GmbH', street: 'Weg 1', zip: '10115', city: 'Berlin', billing_email: 'alpha@example.test' }); expect(orgA!.legacy_ref).toContain(`keyhelp:${conn.json().id}:1`);
const ownerA = await one("SELECT u.* FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.org_id = ?", [r1[0].orgId]); expect(ownerA).toMatchObject({ email: 'alpha@example.test', status: 'invited', kind: 'customer' });
expect((await one('SELECT COUNT(*) n FROM mail_log'))!.n).toBe(0); // es wurde nichts versendet
const ct = (await call(app, admin, 'GET', `/contracts?org=${r1[0].orgId}`)).json()[0]; expect(ct).toMatchObject({ status: 'active', productName: 'Starter' }); expect(ct.price.imported).toBe(true); expect(ct.price.recurring.gross).toBe(999); expect(ct.resourceId).toBe(alphaRes.id);
expect(new Date(ct.termEnd).getTime()).toBeGreaterThan(Date.now()); // Laufzeit bis in die Zukunft fortgeschrieben
const betaRes = (await call(app, admin, 'GET', `/resources?org=${r1[1].orgId}`)).json(); expect(betaRes).toHaveLength(1);
// Wiederholung / Konflikte
expect((await imp([{ externalRef: '1', type: 'business' }])).json().results[0]).toMatchObject({ status: 'skipped', reason: 'Bereits übernommen' });
expect((await imp([{ externalRef: '999', type: 'business' }])).json().results[0].status).toBe('skipped');
// Verknüpfen mit vorhandenem Kunden statt neu anlegen
fake.state.clients.push({ id: 3, status: 1, username: 'gamma', email: 'alpha@example.test', is_suspended: false, id_hosting_plan: 1, created_at: '2026-03-01 10:00:00', contact_data: { company: 'Gamma' }, permissions: {} }); fake.state.domains.push({ id: 70, id_user: 3, domain: 'gamma.example.test', status: 1, security: {} });
await run("UPDATE connector_instances SET last_sync_at = NULL"); await call(app, sa, 'POST', `/admin/connectors/${conn.json().id}/sync`); await drain();
const dup = (await imp([{ externalRef: '3', type: 'business' }])).json().results[0]; expect(dup.status).toBe('skipped'); expect(dup.reason).toMatch(/bereits einem Benutzer/); // E-Mail vergeben
const lk = (await imp([{ externalRef: '3', type: 'business', linkToOrgId: r1[0].orgId }])).json().results[0]; expect(lk).toMatchObject({ status: 'linked', orgId: r1[0].orgId });
// ---- Einladung gezielt nachträglich senden
expect((await call(app, sup, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).statusCode).toBe(403);
const inv = (await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json(); expect(inv.mail).toBe('not_configured'); expect(inv.inviteLink).toContain('/einladung?token=');
expect((await accept(inv.inviteLink, 'passwort-alpha-123')).statusCode).toBe(200);
expect((await call(app, admin, 'POST', `/admin/users/${ownerA!.id}/reinvite`)).json().error.code).toBe('NOT_INVITED');
const owner = (await login(app, 'alpha@example.test', 'passwort-alpha-123')).client;
// ---- Kunde nutzt sein Hosting: Panel-Login, Unterobjekte lesen (Mandantentrennung), Änderungen als Auftrag
const cres = (await call(app, owner, 'GET', '/resources')).json().find((r: any) => r.name.includes('alpha'));
const d2 = (await call(app, owner, 'GET', `/resources/${cres.id}`)).json(); expect(d2).toMatchObject({ hasChildren: true, canLogin: true });
const lg = await call(app, owner, 'POST', `/resources/${cres.id}/login`); expect(lg.statusCode).toBe(200); expect(lg.json()).toEqual({ url: 'https://kh.test/login?token=EINMALIG-1', validForSec: 3600 }); expect(lg.headers['cache-control']).toBe('no-store');
expect((await call(app, owner, 'POST', `/resources/${betaRes[0].id}/login`)).statusCode).toBe(404); // Konto eines anderen Kunden
expect((await query("SELECT COUNT(*) n FROM audit_events WHERE action = 'resource.login'"))[0]!.n).toBe(1); expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('EINMALIG');
const kinds = (await call(app, owner, 'GET', `/resources/${cres.id}/children`)).json(); expect(kinds.kinds.find((k: any) => k.kind === 'email').canWrite).toBe(true); expect(kinds.kinds.find((k: any) => k.kind === 'database').canWrite).toBe(false); expect(kinds.kinds.find((k: any) => k.kind === 'certificate').canWrite).toBe(false);
const doms = (await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`)).json(); expect(doms.map((d: any) => d.name)).toContain('alpha.example.test'); expect(doms.map((d: any) => d.name)).not.toContain('beta.example.test');
const impd = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd.statusCode).toBe(200); expect(impd.json().added).toBeGreaterThanOrEqual(1); expect(impd.json().unpriced).toBeGreaterThanOrEqual(1);
const impd2 = await call(app, sa, 'POST', `/admin/domain-records/from-resource/${cres.id}`); expect(impd2.json().added).toBe(0); // zweiter Lauf legt nichts doppelt an
expect((await call(app, owner, 'GET', `/resources/${cres.id}/children/email`)).json().map((e: any) => e.name)).toEqual(['info@alpha.example.test']);
// Schreiben: Passwortregeln, Freigabe je Art, Ausführung im Hintergrund, Passwort nirgends gespeichert
const post = (kind: string, body: object, r = cres.id) => call(app, owner, 'POST', `/resources/${r}/children/${kind}`, body);
expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'kurz' })).json().error.code).toBe('WEAK_PASSWORD');
expect((await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' } })).json().error.code).toBe('PASSWORD_REQUIRED');
expect((await post('database', { op: 'create', data: {}, password: 'ein-langes-passwort-1' })).json().error.code).toBe('ACTION_NOT_ALLOWED'); // database.manage nicht freigegeben
expect((await post('certificate', { op: 'delete', id: '60', data: {} })).statusCode).toBe(403);
const ok = await post('email', { op: 'create', data: { local: 'kontakt', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' }); expect(ok.statusCode).toBe(202);
const jobRow = await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]); expect(JSON.stringify(jobRow!.payload)).not.toContain('sehr-langes'); expect(JSON.stringify(jobRow!.payload)).toContain('secretEnc'); // nur verschlüsselt im Auftrag
await drain();
expect((await one('SELECT status, last_error, payload FROM jobs WHERE id = ?', [ok.json().jobId]))).toMatchObject({ status: 'succeeded' });
expect(JSON.stringify((await one('SELECT payload FROM jobs WHERE id = ?', [ok.json().jobId]))!.payload)).not.toContain('secretEnc'); // nach Ausführung entfernt
expect(fake.state.emails.map((e) => e.email)).toContain('kontakt@alpha.example.test');
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('sehr-langes'); expect(JSON.stringify(await query('SELECT payload FROM jobs'))).not.toContain('sehr-langes');
// Doppelklick: gleicher Schlüssel = ein Auftrag
const hdr = { cookie: owner.cookie, 'x-csrf-token': owner.csrf, 'idempotency-key': 'mail-doppelt-0001' }; const body = { op: 'create', data: { local: 'zwei', domain: 'alpha.example.test' }, password: 'ein-sehr-langes-Passwort-42' };
const a1 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr }); const a2 = await app.inject({ method: 'POST', url: `/v1/resources/${cres.id}/children/email`, payload: body, headers: hdr });
expect(a2.json().jobId).toBe(a1.json().jobId); expect(a2.json().duplicate).toBe(true); await drain(); expect(fake.state.emails.filter((e) => e.email === 'zwei@alpha.example.test').length).toBe(1);
// Fremdes Objekt löschen: der Auftrag scheitert, nichts wird verändert
const before = JSON.stringify(fake.state.emails);
const del = await post('email', { op: 'delete', id: '31', data: {} }); expect(del.statusCode).toBe(202); await drain();
expect((await one('SELECT status, last_error FROM jobs WHERE id = ?', [del.json().jobId]))).toMatchObject({ status: 'failed' }); expect(JSON.stringify(fake.state.emails)).toBe(before);
// Eigenes Objekt löschen
const delOwn = await post('email', { op: 'delete', id: String(fake.state.emails.find((e) => e.email === 'zwei@alpha.example.test')!.id), data: {} }); await drain();
expect(fake.state.emails.some((e) => e.email === 'zwei@alpha.example.test')).toBe(false); expect((await one('SELECT status FROM jobs WHERE id = ?', [delOwn.json().jobId]))!.status).toBe('succeeded');
// Sperren durch Kunden (freigegeben) läuft über den bestehenden Auftrag
const sus = await call(app, owner, 'POST', `/resources/${cres.id}/actions`, { action: 'suspend' }); expect(sus.statusCode).toBe(202); await drain(); expect(fake.state.clients.find((c) => c.id === 1)!.is_suspended).toBe(true);
// Anbieter-Ausfall: verständlicher Fehler, kein Absturz
fake.opts.failGet503 = 50; const down = await call(app, owner, 'GET', `/resources/${cres.id}/children/domain`); expect(down.statusCode).toBe(502); fake.opts.failGet503 = 0;
});
});

View file

@ -0,0 +1,268 @@
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 { enqueue } from '../src/core/jobs.js';
import { processContractLifecycle, provisionOrder } from '@kc/connectors';
import { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) });
return client;
}
async function customer(admin: any, type: 'private' | 'business', name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type, name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
const version = (over: object = {}) => ({ name: 'Lizenz Pro', taxRateId: '', setupCents: 1000, recurringCents: 4999, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {}, ...over });
describe('Produkte, Bestellungen, Verträge', () => {
it('Katalog, Bestellung, Freigabe, Bereitstellung, Snapshot, Kündigung, Mandantentrennung', async () => {
resetMock();
const admin = await staff('adm@shop.test', 'admin'); const sa = await staff('sa@shop.test', 'superadmin'); const sup = await staff('sup@shop.test', 'support');
const A = await customer(admin, 'business', 'Firma A', 'a@shop.test'); const B = await customer(admin, 'business', 'Firma B', 'b@shop.test'); const P = await customer(admin, 'private', 'Erika Muster', 'p@shop.test');
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Shop', values: { instance: 'shop' } })).json();
await drain();
const tax = (await call(app, admin, 'GET', '/admin/tax-rates')).json(); const t19 = tax.find((t: any) => t.rateBp === 1900).id;
expect(tax.map((t: any) => t.rateBp).sort((x: number, y: number) => x - y)).toEqual([0, 700, 1900]);
// Rechte + Regeln beim Anlegen
const mkBody = (v: object, o: object = {}) => ({ sku: 'LIC-PRO', category: 'license', connectorInstanceId: conn.id, orderableByCustomer: true, requiresApproval: true, customerActions: ['suspend'], status: 'active', version: { ...version({ taxRateId: t19 }), ...v }, ...o });
expect((await call(app, sup, 'POST', '/admin/products', mkBody({}))).statusCode).toBe(403);
expect((await call(app, admin, 'POST', '/admin/products', mkBody({ billingInterval: 'once', recurringCents: 100, termMonths: 0, renewal: 'none' }))).json().error.code).toBe('BAD_TERMS');
expect((await call(app, admin, 'POST', '/admin/products', mkBody({ termMonths: 5 }))).json().error.code).toBe('BAD_TERMS'); // 5 kein Vielfaches von 12
const prod = (await call(app, admin, 'POST', '/admin/products', mkBody({}))).json();
expect((await call(app, admin, 'POST', '/admin/products', mkBody({}))).json().error.code).toBe('SKU_EXISTS');
// Kundenkatalog: Preise serverseitig (Netto/Brutto), nur für eigene Organisation
const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json();
expect(cat).toHaveLength(1); expect(cat[0].price.recurring).toEqual({ net: 4999, tax: 950, gross: 5949 });
expect((await call(app, A.client, 'GET', `/catalog?org=${B.org}`)).statusCode).toBe(404);
// Bestellen: Rabatt verboten, ohne Freigabe nicht ausführbar, fremde Org verboten
const order = (over: object = {}, c = A) => call(app, c.client, 'POST', '/orders', { orgId: c.org, items: [{ productId: prod.id }], ...over });
expect((await order({ items: [{ productId: prod.id, discountBp: 1000 }] })).statusCode).toBe(403);
expect((await call(app, B.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.id }] })).statusCode).toBe(404);
const o1 = (await order()).json(); expect(o1.status).toBe('pending_approval'); expect(o1.number).toMatch(/^B-/);
expect((await call(app, A.client, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403);
expect((await call(app, sup, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(403);
expect((await one("SELECT COUNT(*) n FROM resources WHERE org_id = ?", [A.org]))!.n).toBe(0);
expect((await call(app, B.client, 'GET', `/orders/${o1.id}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'GET', '/orders')).json()).toHaveLength(0);
// Freigabe -> Bereitstellung über Worker -> Vertrag aktiv, Ressource beim Kunden
expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(200);
expect((await call(app, admin, 'POST', `/admin/orders/${o1.id}/approve`)).statusCode).toBe(409); // nicht doppelt freigeben
await drain();
const done = (await call(app, A.client, 'GET', `/orders/${o1.id}`)).json();
expect(done.status).toBe('completed');
const ct = (await call(app, A.client, 'GET', '/contracts')).json()[0];
expect(ct.status).toBe('active'); expect(ct.number).toMatch(/^V-/); expect(ct.resourceId).toBeTruthy();
const months = (new Date(ct.termEnd).getTime() - new Date(ct.startedAt).getTime()) / 86400000; expect(months).toBeGreaterThan(360); expect(months).toBeLessThan(370);
const res = (await call(app, A.client, 'GET', `/resources/${ct.resourceId}`)).json();
expect(res.state).toBe('active'); expect(res.allowedActions).toEqual(['suspend']); // Produktregel begrenzt Kundenaktionen
expect((await call(app, B.client, 'GET', `/resources/${ct.resourceId}`)).statusCode).toBe(404);
expect((await call(app, B.client, 'GET', `/contracts/${ct.id}`)).statusCode).toBe(404);
// Bereitstellung ist idempotent: erneuter Lauf erzeugt nichts Neues
const before = (await one('SELECT COUNT(*) n FROM resources'))!.n;
await run("UPDATE orders SET status='provisioning' WHERE id = ?", [o1.id]);
await provisionOrder(o1.id, { id: 'x', correlationId: 'c', attempt: 1, maxAttempts: 5 });
expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(before);
expect((await one('SELECT status FROM orders WHERE id = ?', [o1.id]))!.status).toBe('completed');
// Preis-Snapshot bleibt bei Preisänderung unverändert; neue Bestellung nutzt neue Version
expect((await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, version({ taxRateId: t19, recurringCents: 9999 }))).statusCode).toBe(200);
expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().price.recurring.net).toBe(4999);
const cat2 = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json(); expect(cat2[0].price.recurring.net).toBe(9999);
// Personal bestellt mit Rabatt: sofort freigegeben, serverseitig berechnet
const o2 = (await call(app, admin, 'POST', '/orders', { orgId: B.org, items: [{ productId: prod.id, discountBp: 1000 }] })).json(); expect(o2.status).toBe('provisioning');
await drain();
const o2d = (await call(app, admin, 'GET', `/orders/${o2.id}`)).json();
expect(o2d.status).toBe('completed'); expect(o2d.items[0].snapshot.recurring).toEqual({ net: 8999, tax: 1710, gross: 10709 }); // 99,99 * 0,9 = 89,991
expect(o2d.items[0].snapshot.setup.net).toBe(900);
// Privatkunde: Verbraucherregel (Verlängerung 1 Monat, Frist max. 30 Tage)
const o3 = (await order({}, P)).json(); await call(app, admin, 'POST', `/admin/orders/${o3.id}/approve`); await drain();
const pc = (await call(app, P.client, 'GET', '/contracts')).json()[0];
expect(pc.renewalTermMonths).toBe(1); expect(pc.noticeDays).toBe(30);
// Kündigung: zum Laufzeitende, widerrufbar, Kunde darf nicht sofort beenden
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`, { immediate: true })).json().error.code).toBe('STAFF_ONLY');
expect((await call(app, B.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(404);
const cn = (await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).json();
expect(new Date(cn.cancelEffectiveAt).getTime()).toBe(new Date(ct.termEnd).getTime());
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`)).statusCode).toBe(409);
expect((await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel/revoke`)).statusCode).toBe(200);
await call(app, A.client, 'POST', `/contracts/${ct.id}/cancel`);
// Zeitsprung: Kündigung wird wirksam -> Vertrag beendet, Ressource gesperrt (nicht gelöscht)
await run('UPDATE contracts SET cancel_effective_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 MINUTE) WHERE id = ?', [ct.id]);
const life = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); expect(life.ended).toBe(1);
await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o)); // idempotent
expect((await query("SELECT id FROM jobs WHERE idempotency_key = ?", [`contract-end:${ct.id}`])).length).toBe(1);
await drain();
expect((await call(app, A.client, 'GET', `/contracts/${ct.id}`)).json().status).toBe('cancelled');
expect((await call(app, admin, 'GET', `/resources/${ct.resourceId}`)).json().state).toBe('suspended');
expect((await one("SELECT COUNT(*) n FROM audit_events WHERE action IN ('order.create','order.approve','contract.activate','contract.cancel.request','contract.cancel.effective')"))!.n).toBeGreaterThanOrEqual(8);
});
it('automatische Verlängerung ohne Kündigung', async () => {
const c = await one("SELECT id FROM contracts WHERE status = 'active' LIMIT 1");
await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 2 DAY) WHERE id = ?', [c!.id]);
const r = await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o));
expect(r.renewed).toBeGreaterThanOrEqual(1);
const after = await one('SELECT term_end, status FROM contracts WHERE id = ?', [c!.id]);
expect(new Date(after!.term_end).getTime()).toBeGreaterThan(Date.now()); expect(after!.status).toBe('active');
});
it('Fehlerfälle: unklarer Ausgang wird nie automatisch wiederholt; sichere Fehler begrenzt', async () => {
const admin = await staff('adm2@shop.test', 'admin'); const sa = await staff('sa2@shop.test', 'superadmin');
const C = await customer(admin, 'business', 'Firma C', 'c@shop.test');
const inst = await one("SELECT id FROM connector_instances LIMIT 1");
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const mk = async (sku: string, failCreate: string) => (await call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: inst!.id, status: 'active', version: version({ taxRateId: t19, provisioning: { failCreate } }) })).json();
const resBefore = (await one('SELECT COUNT(*) n FROM resources'))!.n;
// Timeout: unklar, ob angelegt -> sofort failed, ambiguous, kein Retry
const pT = await mk('FAIL-TIMEOUT', 'timeout');
const oT = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pT.id }] })).json(); await drain();
const dT = (await call(app, admin, 'GET', `/orders/${oT.id}`)).json();
expect(dT.status).toBe('failed'); expect(dT.failureAmbiguous).toBe(true); expect(dT.failureNote).toMatch(/unklar/);
expect((await one("SELECT attempts, status FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oT.id]))).toMatchObject({ attempts: 1, status: 'needs_review' });
expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, {})).json().error.code).toBe('CONFIRM_REQUIRED');
expect((await call(app, admin, 'POST', `/admin/orders/${oT.id}/retry`, { confirmChecked: true })).statusCode).toBe(200);
// Verbindung verweigert: sicher nicht gesendet -> begrenzt wiederholt, dann failed (nicht ambiguous)
const pU = await mk('FAIL-UNREACH', 'unreachable');
const oU = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: pU.id }] })).json(); await drain();
const dU = (await call(app, admin, 'GET', `/orders/${oU.id}`)).json();
expect(dU.status).toBe('failed'); expect(dU.failureAmbiguous).toBe(false);
expect((await one("SELECT attempts FROM jobs WHERE type='order.provision' AND JSON_VALUE(payload,'$.orderId') = ?", [oU.id]))!.attempts).toBe(5);
expect((await one('SELECT COUNT(*) n FROM resources'))!.n).toBe(resBefore);
// Verträge der fehlgeschlagenen Bestellung bleiben ausstehend (nicht aktiv)
expect((await call(app, admin, 'GET', `/contracts?org=${C.org}`)).json().every((c: any) => c.status === 'pending')).toBe(true);
// Zurückziehen einer gescheiterten Bestellung beendet die Verträge
expect((await call(app, admin, 'POST', `/orders/${oU.id}/cancel`)).statusCode).toBe(200);
});
});
describe('Produktübernahme aus Verbindungen', () => {
it('liest Vorlagen des Anbieters, übernimmt sie mit Details und zählt Übernahmen', async () => {
resetMock();
const admin = await staff('imp-adm@shop.test', 'admin'); const sa = await staff('imp-sa@shop.test', 'superadmin'); const sup = await staff('imp-sup@shop.test', 'support');
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Import', values: { instance: 'imp' } })).json();
await drain();
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
expect((await call(app, sup, 'GET', `/admin/connectors/${conn.id}/catalog`)).statusCode).toBe(403);
const cat = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json();
expect(cat.map((c: any) => c.externalRef)).toEqual(['mock-prog-1', 'mock-prog-2']);
expect(cat.every((c: any) => c.importedProducts === 0)).toBe(true);
expect(cat[0].hints[0].label).toBe('5 Benutzer');
// Übernahme mit selbst definierten Details (zwei Varianten desselben Angebots)
const imp = (sku: string, over: object = {}) => call(app, admin, 'POST', '/admin/products', { sku, category: 'license', connectorInstanceId: conn.id, externalRef: 'mock-prog-1', orderableByCustomer: true, requiresApproval: false, customerActions: ['suspend'],
version: { name: 'Alpha Jahreslizenz', taxRateId: t19, setupCents: 0, recurringCents: 12000, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: { userLimit: 5 }, ...over } });
const a1 = await imp('ALPHA-Y'); expect(a1.statusCode).toBe(200);
expect((await imp('ALPHA-M', { name: 'Alpha Monatslizenz', recurringCents: 1200, billingInterval: 'monthly', termMonths: 0, renewalTermMonths: 1 })).statusCode).toBe(200);
const cat2 = (await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`)).json();
expect(cat2.find((c: any) => c.externalRef === 'mock-prog-1').importedProducts).toBe(2);
expect(cat2.find((c: any) => c.externalRef === 'mock-prog-2').importedProducts).toBe(0);
const p = (await call(app, admin, 'GET', `/admin/products/${a1.json().id}`)).json(); expect(p.externalRef).toBe('mock-prog-1');
// Herkunft nur mit Verbindung
expect((await call(app, admin, 'POST', '/admin/products', { sku: 'X-1', category: 'license', externalRef: 'x', version: { name: 'X', taxRateId: t19, billingInterval: 'once', provisioning: {} } })).json().error.code).toBe('BAD_CONNECTOR');
// Ausfall des Anbieters: verständlicher Fehler statt Absturz
await call(app, sa, 'PATCH', `/admin/connectors/${conn.id}`, { values: { simulateOutage: 'true' } });
const down = await call(app, admin, 'GET', `/admin/connectors/${conn.id}/catalog`);
expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/);
});
});
describe('Bruttopreise', () => {
it('bestellt zu exakten Endkundenpreisen (Privatkunde 9,99 € brutto) und speichert die Basis im Snapshot', async () => {
const admin = await staff('gross-adm@shop.test', 'admin');
const P = await customer(admin, 'private', 'Erika Muster', 'gross-p@shop.test');
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'GROSS-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false,
version: { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} } })).json();
const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json();
expect(cat[0].price.recurring).toEqual({ net: 839, tax: 160, gross: 999 }); expect(cat[0].price.basis).toBe('gross');
const o = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] })).json();
await drain();
const d = (await call(app, P.client, 'GET', `/orders/${o.id}`)).json();
expect(d.status).toBe('completed'); expect(d.items[0].snapshot.recurring.gross).toBe(999); expect(d.items[0].snapshot.basis).toBe('gross');
// Preisänderung gilt nur für neue Bestellungen; alter Vertrag bleibt 9,99 €
await call(app, admin, 'POST', `/admin/products/${prod.id}/versions`, { name: 'Beispiel brutto', taxRateId: t19, priceBasis: 'gross', recurringCents: 1299, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} });
expect((await call(app, P.client, 'GET', '/contracts')).json()[0].price.recurring.gross).toBe(999);
});
});
describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => {
resetMock();
const admin = await staff('ft-adm@shop.test', 'admin'); const sa = await staff('ft-sa@shop.test', 'superadmin'); const sup = await staff('ft-sup@shop.test', 'support');
const P = await customer(admin, 'private', 'Erika Muster', 'ft-p@shop.test');
const conn = (await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-FT', values: { instance: 'ft' } })).json(); await drain();
expect((await call(app, sup, 'GET', '/admin/product-bundles')).statusCode).toBe(403);
const bundles = (await call(app, admin, 'GET', '/admin/product-bundles')).json();
const ft = bundles.find((b: any) => b.key === 'familytool-vorlage'); expect(ft.products.map((p: any) => p.sku)).toEqual(['FT-PREMIUM-M', 'FT-PREMIUM-Y', 'FT-UNLIMITED-M', 'FT-UNLIMITED-Y', 'FT-LIFETIME', 'FT-TRIAL']);
expect(ft.products.find((p: any) => p.sku === 'FT-UNLIMITED-M').recurringCents).toBe(999);
const skus = ft.products.map((p: any) => p.sku);
const imp = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json();
expect(imp.created).toHaveLength(6); expect(imp.skipped).toHaveLength(0);
// nochmals importieren: alles übersprungen (Artikelnummern existieren), nichts doppelt
const again = (await call(app, admin, 'POST', '/admin/product-bundles/familytool-vorlage/import', { connectorInstanceId: conn.id, programRef: 'mock-prog-1', skus })).json();
expect(again.created).toHaveLength(0); expect(again.skipped).toHaveLength(6);
const list = (await call(app, admin, 'GET', '/admin/products')).json(); expect(list.filter((p: any) => p.sku.startsWith('FT-')).every((p: any) => p.status === 'draft')).toBe(true);
const byS = (s: string) => list.find((p: any) => p.sku === s);
expect(byS('FT-PREMIUM-Y').provisioning).toMatchObject({ keyPrefix: 'PREMIUM', durationType: 'YEAR' });
// Aktivierungssperre: Anbieter meldet Präfix-Unterstützung nicht -> 400, Entwurf bleibt
await run("UPDATE connector_instances SET capabilities_json = JSON_REMOVE(capabilities_json, '$[11]', '$[10]') WHERE id = ?", [conn.id]);
const caps = (await one('SELECT capabilities_json c FROM connector_instances WHERE id = ?', [conn.id]))!.c; expect(JSON.stringify(caps)).not.toContain('license.key_prefix');
const blocked = await call(app, admin, 'PATCH', `/admin/products/${byS('FT-PREMIUM-M').id}`, { status: 'active' });
expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('NEEDS_LICENSE_EXTENSION');
// Unlimited ohne Präfix ist trotzdem aktivierbar
expect((await call(app, admin, 'PATCH', `/admin/products/${byS('FT-UNLIMITED-M').id}`, { status: 'active' })).statusCode).toBe(200);
// Nach Erweiterung (Abgleich meldet Fähigkeit wieder) sind alle aktivierbar
await call(app, sa, 'POST', `/admin/connectors/${conn.id}/sync`); await drain();
for (const s of ['FT-PREMIUM-M', 'FT-PREMIUM-Y']) expect((await call(app, admin, 'PATCH', `/admin/products/${byS(s).id}`, { status: 'active' })).statusCode).toBe(200);
// Privatkunde bestellt Premium monatlich: exakt 2,99 € brutto, Edition PREMIUM, rollierender Monatsvertrag
const cat = (await call(app, P.client, 'GET', `/catalog?org=${P.org}`)).json();
expect(cat.find((c: any) => c.sku === 'FT-PREMIUM-M').price.recurring).toEqual({ net: 251, tax: 48, gross: 299 });
const oM = (await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-PREMIUM-M').id }] })).json(); expect(oM.status).toBe('provisioning'); await drain();
const cM = (await call(app, P.client, 'GET', '/contracts')).json()[0];
expect(cM.status).toBe('active'); expect(cM.termEnd).toBeTruthy(); // Verlängerungstakt trotz Mindestlaufzeit 0
const rM = (await call(app, P.client, 'GET', `/resources/${cM.resourceId}`)).json(); expect(rM.details.edition).toBe('PREMIUM');
// Kunde und Herkunft wurden an den Anbieter gemeldet
expect(rM.details.context).toMatchObject({ source: 'kundencenter', customerName: 'Erika Muster', customerEmail: 'ft-p@shop.test', contractNumber: cM.number });
expect(rM.details.context.customerNumber).toMatch(/^K-\d+$/); expect(rM.details.context.orderNumber).toMatch(/^B-\d+$/);
// Vertragsverlängerung zieht die Lizenz mit
const until0 = new Date(rM.validUntil).getTime();
await run('UPDATE contracts SET term_end = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 DAY) WHERE id = ?', [cM.id]);
expect((await processContractLifecycle(new Date(), (t, p, o) => enqueue(t, p, o))).renewed).toBe(1); await drain();
const after = (await call(app, admin, 'GET', `/resources/${cM.resourceId}`)).json();
const cAfter = (await call(app, admin, 'GET', `/contracts/${cM.id}`)).json();
expect(new Date(after.validUntil).toISOString()).toBe(new Date(cAfter.termEnd).toISOString()); expect(new Date(after.validUntil).getTime()).toBeGreaterThan(until0 - 86400000);
// Lifetime: Freigabe nötig, Edition LIFETIME, unbefristet, keine Verlängerung
await call(app, admin, 'PATCH', `/admin/products/${byS('FT-LIFETIME').id}`, { status: 'active' });
const oL = (await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-LIFETIME').id }] })).json(); await drain();
const cL = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.id !== cM.id);
expect(cL.termEnd).toBeNull(); expect((await call(app, admin, 'GET', `/resources/${cL.resourceId}`)).json().details.edition).toBe('LIFETIME');
expect(cL.price.setup.gross).toBe(15900);
// Trial: 14 Tage Gültigkeit, Edition TRIAL
await call(app, admin, 'PATCH', `/admin/products/${byS('FT-TRIAL').id}`, { status: 'active' });
await call(app, admin, 'POST', '/orders', { orgId: P.org, items: [{ productId: byS('FT-TRIAL').id }] }); await drain();
const trial = (await call(app, admin, 'GET', `/contracts?org=${P.org}`)).json().find((c: any) => c.price.sku === 'FT-TRIAL');
const tr = (await call(app, admin, 'GET', `/resources/${trial.resourceId}`)).json(); expect(tr.details.edition).toBe('TRIAL');
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
});
});

View file

@ -0,0 +1,65 @@
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 { resetMock } from '@kc/connector-mock';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role });
const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json();
await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) });
return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
describe('Schlüssel auf Abruf', () => {
it('zeigt den vollständigen Schlüssel nur Berechtigten, nie in Listen/Details/Audit', async () => {
resetMock();
const admin = await staff('rv-adm@x.test', 'admin'); const sa = await staff('rv-sa@x.test', 'superadmin'); const sup = await staff('rv-sup@x.test', 'support');
const A = await customer(admin, 'Firma A', 'rv-a@x.test'); const B = await customer(admin, 'Firma B', 'rv-b@x.test');
// Mitglied ohne Verwaltungsrecht in A
const inv = (await call(app, A.client, 'POST', `/orgs/${A.org}/invitations`, { email: 'rv-m@x.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, 'rv-m@x.test', 'member-passwort-1')).client;
await call(app, sa, 'POST', '/admin/connectors', { connector: 'mock', name: 'Mock-Reveal', values: { instance: 'rv' } }); await drain();
const res = (await call(app, admin, 'GET', '/resources')).json()[0];
await call(app, admin, 'PATCH', `/admin/resources/${res.id}`, { orgId: A.org });
// Liste: Schlüsselfeld nur für Berechtigte
expect((await call(app, A.client, 'GET', '/resources')).json()[0].canReveal).toBe(true);
expect((await call(app, member, 'GET', '/resources')).json()[0].canReveal).toBe(false);
expect((await call(app, sup, 'GET', '/resources')).json()[0].canReveal).toBe(false);
const d = (await call(app, A.client, 'GET', `/resources/${res.id}`)).json();
expect(d.canReveal).toBe(true); expect((await call(app, member, 'GET', `/resources/${res.id}`)).json().canReveal).toBe(false);
expect(JSON.stringify(d)).not.toContain('MOCK-KEY'); // Details enthalten den Schlüssel nie
const ok = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`);
expect(ok.statusCode).toBe(200); expect(ok.json().items).toEqual([{ label: 'Lizenzschlüssel', value: `MOCK-KEY-${res.externalRef ?? 'm-1'}` }]);
expect(ok.headers['cache-control']).toBe('no-store'); expect(ok.json().hideAfterSec).toBe(60);
expect((await call(app, admin, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(200); // Personal mit Schreibrecht
expect((await call(app, member, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Mitglied darf nicht
expect((await call(app, sup, 'POST', `/resources/${res.id}/reveal`)).json().error.code).toBe('REVEAL_FORBIDDEN'); // Support nur lesend
expect((await call(app, B.client, 'POST', `/resources/${res.id}/reveal`)).statusCode).toBe(404); // fremde Organisation
expect((await call(app, A.client, 'POST', `/resources/${res.id}/reveal`, undefined)).statusCode).toBe(200);
const anon = await app.inject({ method: 'POST', url: `/v1/resources/${res.id}/reveal` }); expect(anon.statusCode).toBe(401);
// Audit: Abruf ist protokolliert, der Schlüssel selbst nirgends
const ev = await query("SELECT actor_id, org_id, after_json FROM audit_events WHERE action = 'resource.reveal'"); expect(ev.length).toBeGreaterThanOrEqual(3);
expect(JSON.stringify(await query('SELECT * FROM audit_events'))).not.toContain('MOCK-KEY');
expect(JSON.stringify(await query('SELECT * FROM resources'))).not.toContain('MOCK-KEY');
// Anbieter-Ausfall: verständlicher Fehler
const inst = await one('SELECT id FROM connector_instances LIMIT 1');
await run("UPDATE connector_instances SET config_json = JSON_SET(config_json, '$.simulateOutage', 'true') WHERE id = ?", [inst!.id]);
const down = await call(app, A.client, 'POST', `/resources/${res.id}/reveal`); expect(down.statusCode).toBe(502); expect(down.json().error.message).toMatch(/nicht erreichbar/);
});
});

17
apps/api/test/setup.ts Normal file
View file

@ -0,0 +1,17 @@
import { beforeAll } from 'vitest';
import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
await c.query('SET FOREIGN_KEY_CHECKS = 0');
for (const t of DATA) await c.query(`TRUNCATE TABLE \`${t}\``);
await c.query('SET FOREIGN_KEY_CHECKS = 1');
await c.query("UPDATE customer_sequences SET next_value = 10000; UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END".split(';')[0]);
await c.query("UPDATE number_sequences SET next_value = CASE name WHEN 'order' THEN 20000 ELSE 30000 END");
await c.query('UPDATE domain_settings SET tier = 1, margin_type = NULL, margin_value = NULL');
await c.end();
});