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 []; } }