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