Stand vor Einführung des Nacht-Agenten
This commit is contained in:
commit
4763548bfb
168 changed files with 12726 additions and 0 deletions
87
apps/api/test/backup.test.ts
Normal file
87
apps/api/test/backup.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue