- AGENTS.md und README.md auf Craftvia umgeschrieben (Regeln, Andockpunkte, Stack mit Garage, tsx-Tests, Gate, Demo-Logins) - ISMS-Dokumente entfernt (SPEC, Prototypen, Lane-Prompts, Übergaben, Konzepte Incidents/Framework); Fundament-Doku (Deploy, Sicherheit, Backup, Identity) bleibt - Fundament-Tests an Craftvia-Rollen/Branding angepasst, Resttreffer certvia/isms in Skripten und Kommentaren bereinigt Gate: prisma generate, migrate status, tsc, lint, build, 22/22 Testskripte grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
7.6 KiB
TypeScript
169 lines
7.6 KiB
TypeScript
// Lane „Konfigurierbarer Backup-Zielspeicher": Store-Auflösung + Präzedenz + fail-secure.
|
|
//
|
|
// Deckt OHNE DB/Redis ab (reine Auflösungslogik `resolveBackupStore` + echte
|
|
// Byte-Persistenz des LocalBackupStore):
|
|
// (1) DB-Config lokal (expliziter Pfad) → LocalBackupStore auf genau diesem Pfad.
|
|
// (2) DB-Config S3 (vollständig) → S3BackupStore (kein still-lokaler Fallback).
|
|
// (3) Fail-secure: DB-Config S3 unvollständig → klarer Fehler (kein Local-Fallback).
|
|
// (4) Präzedenz DB → Env → Default (inkl. „unberührter Default-Datensatz fällt auf Env").
|
|
// (5) „Verbindung testen"-Pfad: put→get→remove eines winzigen Test-Keys (Local, echt).
|
|
//
|
|
// Lauf: npx tsx scripts/test-backup-target.ts (braucht KEINE DB und KEIN Redis)
|
|
|
|
import "dotenv/config";
|
|
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { resolveBackupStore, type BackupTargetConfig } from "../src/server/storage/backup-store";
|
|
|
|
let failures = 0;
|
|
const ok = (cond: boolean, msg: string) => {
|
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
/** Minimal-Config-Fabrik (nur die relevanten Felder überschreiben). */
|
|
function cfg(partial: Partial<BackupTargetConfig>): BackupTargetConfig {
|
|
return {
|
|
backupTarget: "local",
|
|
backupLocalDir: null,
|
|
backupS3Endpoint: null,
|
|
backupS3Bucket: null,
|
|
backupS3Region: null,
|
|
backupS3AccessKey: null,
|
|
backupS3SecretKey: null,
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
const storeKind = (s: unknown) => (s as { constructor: { name: string } }).constructor.name;
|
|
|
|
/** Env-Variablen für einen Testblock setzen/entfernen und danach wiederherstellen. */
|
|
function withEnv(vars: Record<string, string | undefined>, fn: () => void) {
|
|
const S3_KEYS = ["S3_ENDPOINT", "S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_BUCKET", "S3_REGION", "BACKUP_LOCAL_DIR"];
|
|
const saved: Record<string, string | undefined> = {};
|
|
for (const k of S3_KEYS) saved[k] = process.env[k];
|
|
try {
|
|
// Erst alle relevanten Keys leeren, dann die gewünschten setzen (deterministisch).
|
|
for (const k of S3_KEYS) delete process.env[k];
|
|
for (const [k, v] of Object.entries(vars)) if (v !== undefined) process.env[k] = v;
|
|
fn();
|
|
} finally {
|
|
for (const k of S3_KEYS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const tmp = mkdtempSync(join(tmpdir(), "cvbktarget-"));
|
|
|
|
// ── (1) DB-Config lokal (expliziter Pfad) ──────────────────────────────────
|
|
console.log("\n(1) DB-Config lokal (expliziter Pfad):");
|
|
const localDir = join(tmp, "db-local");
|
|
withEnv({}, () => {
|
|
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: localDir }));
|
|
ok(storeKind(store) === "LocalBackupStore", "explizite lokale DB-Config → LocalBackupStore");
|
|
});
|
|
{
|
|
// Byte-Persistenz auf genau diesem Pfad nachweisen.
|
|
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: localDir }));
|
|
await store.put("t/probe.txt", Buffer.from("hi", "utf8"));
|
|
ok(existsSync(join(localDir, "t/probe.txt")), "Bytes landen unter dem konfigurierten Pfad");
|
|
ok(readFileSync(join(localDir, "t/probe.txt"), "utf8") === "hi", "Inhalt korrekt persistiert");
|
|
}
|
|
|
|
// ── (2) DB-Config S3 vollständig ────────────────────────────────────────────
|
|
console.log("\n(2) DB-Config S3 (vollständig):");
|
|
withEnv({}, () => {
|
|
const store = resolveBackupStore(
|
|
cfg({
|
|
backupTarget: "s3",
|
|
backupS3Endpoint: "http://minio:9000",
|
|
backupS3Bucket: "craftvia-backups",
|
|
backupS3AccessKey: "AK",
|
|
backupS3SecretKey: "SK",
|
|
}),
|
|
);
|
|
ok(storeKind(store) === "S3BackupStore", "vollständige S3-DB-Config → S3BackupStore (kein Local-Fallback)");
|
|
});
|
|
|
|
// ── (3) Fail-secure: S3 unvollständig ───────────────────────────────────────
|
|
console.log("\n(3) Fail-secure (S3 unvollständig):");
|
|
withEnv({ S3_ENDPOINT: "http://env-minio:9000", S3_ACCESS_KEY: "E", S3_SECRET_KEY: "E", S3_BUCKET: "env" }, () => {
|
|
let threw = false;
|
|
try {
|
|
resolveBackupStore(cfg({ backupTarget: "s3", backupS3Endpoint: "http://minio:9000", backupS3Bucket: "b" }));
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
ok(threw, "S3-DB-Config ohne Secret/Access-Key wirft (fällt NICHT still auf lokal/Env)");
|
|
});
|
|
|
|
// ── (4) Präzedenz DB → Env → Default ────────────────────────────────────────
|
|
console.log("\n(4) Präzedenz DB → Env → Default:");
|
|
// (4a) Keine DB-Config, aber Env-S3 vollständig → S3 (Rückwärtskompatibilität).
|
|
withEnv({ S3_ENDPOINT: "http://env-minio:9000", S3_ACCESS_KEY: "E", S3_SECRET_KEY: "E", S3_BUCKET: "env" }, () => {
|
|
ok(storeKind(resolveBackupStore(null)) === "S3BackupStore", "cfg=null + Env-S3 → S3 (Env-Fallback)");
|
|
});
|
|
// (4b) „Unberührter Default-Datensatz" (local/NULL) fällt ebenfalls auf Env zurück.
|
|
withEnv({ S3_ENDPOINT: "http://env-minio:9000", S3_ACCESS_KEY: "E", S3_SECRET_KEY: "E", S3_BUCKET: "env" }, () => {
|
|
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: null }));
|
|
ok(storeKind(store) === "S3BackupStore", "DB local/NULL (unkonfiguriert) → Env-S3 gewinnt (Rückwärtskompatibilität)");
|
|
});
|
|
// (4c) Keine DB-Config, kein Env-S3, aber BACKUP_LOCAL_DIR gesetzt → Local dort.
|
|
const envLocalDir = join(tmp, "env-local");
|
|
withEnv({ BACKUP_LOCAL_DIR: envLocalDir }, async () => {
|
|
const store = resolveBackupStore(null);
|
|
ok(storeKind(store) === "LocalBackupStore", "cfg=null + kein Env-S3, aber BACKUP_LOCAL_DIR → Local");
|
|
});
|
|
{
|
|
const store = withEnvReturn({ BACKUP_LOCAL_DIR: envLocalDir }, () => resolveBackupStore(null));
|
|
await store.put("p.txt", Buffer.from("x", "utf8"));
|
|
ok(existsSync(join(envLocalDir, "p.txt")), "Env-BACKUP_LOCAL_DIR steuert den Ablagepfad");
|
|
}
|
|
// (4d) Nichts gesetzt → lokaler Default <cwd>/.backups.
|
|
withEnv({}, () => {
|
|
const store = resolveBackupStore(null);
|
|
ok(storeKind(store) === "LocalBackupStore", "cfg=null + keinerlei Env → lokaler Default (.backups)");
|
|
});
|
|
|
|
// ── (5) „Verbindung testen"-Pfad (put→get→remove) ───────────────────────────
|
|
console.log("\n(5) „Verbindung testen\"-Pfad (Local, put→get→remove):");
|
|
{
|
|
const store = resolveBackupStore(cfg({ backupTarget: "local", backupLocalDir: join(tmp, "conn") }));
|
|
const key = "__connectivity-test__/probe.txt";
|
|
const payload = Buffer.from("craftvia connectivity", "utf8");
|
|
await store.put(key, payload);
|
|
const back = await store.get(key);
|
|
ok(!!back && back.equals(payload), "put→get liefert identische Bytes");
|
|
await store.remove(key);
|
|
ok((await store.get(key)) === null, "remove entfernt den Test-Key wieder (kein Rückstand)");
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
/** Wie withEnv, aber gibt den Rückgabewert der Funktion durch (für synchrone Fälle). */
|
|
function withEnvReturn<T>(vars: Record<string, string | undefined>, fn: () => T): T {
|
|
let out!: T;
|
|
withEnv(vars, () => {
|
|
out = fn();
|
|
});
|
|
return out;
|
|
}
|
|
|
|
main()
|
|
.then(() => {
|
|
if (failures > 0) {
|
|
console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`);
|
|
process.exit(1);
|
|
}
|
|
console.log("\nOK");
|
|
})
|
|
.catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|