kundencenter/apps/api/test/backup.test.ts

88 lines
7.3 KiB
TypeScript
Raw Normal View History

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