internal/tenantbackup: datenbank-scharfes pg_dump/pg_restore statt BAK-01s Cluster-weitem pg_basebackup - bei Modell C (TEN-01, physisch isolierte DB je Mandant) wuerde ein Cluster-Restore zwangslaeufig ALLE Mandanten ueberschreiben. Objekt-Seite nutzt BAK-02 direkt (Mandanten haben eigene Buckets/Pfad-Roots). Eigene Postgres-Rolle nexarch_tenantbackup (CREATEDB, kein Superuser, getrennt von nexarch_backup). Zwei-Tenant-Isolation real in beide Richtungen bewiesen (Markerwert-Nachweis), JSONL-Protokoll fuer Sicherung UND Restore. Realer End-zu-Ende-Lauf ueber tenantbackup-cli auf 131.
204 lines
7.2 KiB
Go
204 lines
7.2 KiB
Go
package tenantbackup
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// requireTestConfig braucht eine EIGENE Rolle mit CREATEDB-Recht
|
|
// (TEST_TENANTBACKUP_PG_USER), NICHT BAK-01/BAK-03s TEST_BACKUP_PG_USER
|
|
// (nexarch_backup) - jene Rolle hat bewusst nur REPLICATION, kein
|
|
// CREATEDB (Prinzip geringster Rechte, siehe BAK-01). BAK-04 braucht
|
|
// stattdessen CREATEDB, um isolierte Ziel-Datenbanken anzulegen -
|
|
// bekommt daher eine eigene, separat scharf gestellte Rolle.
|
|
func requireTestConfig(t *testing.T) Config {
|
|
t.Helper()
|
|
user := os.Getenv("TEST_TENANTBACKUP_PG_USER")
|
|
if user == "" {
|
|
t.Skip("TEST_TENANTBACKUP_PG_USER nicht gesetzt, Integrationstest uebersprungen (braucht echte Postgres-Rolle mit CREATEDB)")
|
|
}
|
|
if _, err := exec.LookPath("pg_dump"); err != nil {
|
|
t.Skip("pg_dump nicht installiert, Integrationstest uebersprungen")
|
|
}
|
|
return Config{
|
|
Host: envOr("TEST_TENANTBACKUP_PG_HOST", "localhost"),
|
|
Port: envOr("TEST_TENANTBACKUP_PG_PORT", "5432"),
|
|
User: user,
|
|
Password: os.Getenv("TEST_TENANTBACKUP_PG_PASSWORD"),
|
|
BackupDir: t.TempDir(),
|
|
}
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// createMarkerDB legt eine frische Testdatenbank mit genau EINER
|
|
// Markierungszeile an (Kennzeichen für "das ist eindeutig Tenant X's
|
|
// Datensatz") - real ueber psql, kein Mock.
|
|
func createMarkerDB(t *testing.T, cfg Config, dbName, marker string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
run := func(args ...string) {
|
|
t.Helper()
|
|
cmd := exec.CommandContext(ctx, cfg.psqlBinary(), args...)
|
|
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("psql %v: %v (ausgabe: %s)", args, err, out)
|
|
}
|
|
}
|
|
base := []string{"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "--no-password"}
|
|
run(append(append([]string{}, base...), "-d", "postgres", "-c", "DROP DATABASE IF EXISTS "+quoteIdent(dbName))...)
|
|
run(append(append([]string{}, base...), "-d", "postgres", "-c", "CREATE DATABASE "+quoteIdent(dbName))...)
|
|
run(append(append([]string{}, base...), "-d", dbName, "-c",
|
|
"CREATE TABLE marker (value TEXT); INSERT INTO marker VALUES ('"+marker+"')")...)
|
|
t.Cleanup(func() {
|
|
run(append(append([]string{}, base...), "-d", "postgres", "-c", "DROP DATABASE IF EXISTS "+quoteIdent(dbName))...)
|
|
})
|
|
}
|
|
|
|
func markerValue(t *testing.T, cfg Config, dbName string) string {
|
|
t.Helper()
|
|
cmd := exec.CommandContext(context.Background(), cfg.psqlBinary(),
|
|
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", dbName, "--no-password",
|
|
"-tAc", "SELECT value FROM marker")
|
|
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("markerwert lesen: %v (ausgabe: %s)", err, out)
|
|
}
|
|
return trimNL(string(out))
|
|
}
|
|
|
|
func trimNL(s string) string {
|
|
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func databaseExists(t *testing.T, cfg Config, dbName string) bool {
|
|
t.Helper()
|
|
cmd := exec.CommandContext(context.Background(), cfg.psqlBinary(),
|
|
"-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password",
|
|
"-tAc", "SELECT 1 FROM pg_database WHERE datname = '"+dbName+"'")
|
|
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
|
out, _ := cmd.CombinedOutput()
|
|
return trimNL(string(out)) == "1"
|
|
}
|
|
|
|
// TestBackupRestore_RecoversExactTenantData ist Pruefung 1 (angepasst
|
|
// auf Akzeptanzkriterium 1): eine Sicherung von Tenant A, wiederhergestellt
|
|
// in eine isolierte Zieldatenbank, enthaelt real Tenant As Datensatz.
|
|
func TestBackupRestore_RecoversExactTenantData(t *testing.T) {
|
|
cfg := requireTestConfig(t)
|
|
ctx := context.Background()
|
|
|
|
tenantA := "tenantbackup_test_a"
|
|
createMarkerDB(t, cfg, tenantA, "gehoert-zu-tenant-a")
|
|
|
|
dumpPath, err := Backup(ctx, cfg, tenantA, time.Now().UTC().Format("20060102T150405Z"))
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
if err := Verify(ctx, cfg, dumpPath); err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
|
|
target := "tenantbackup_test_a_restored"
|
|
if databaseExists(t, cfg, target) {
|
|
t.Fatalf("zieldatenbank %q existiert bereits vor dem test", target)
|
|
}
|
|
if err := CreateEmptyDatabase(ctx, cfg, target); err != nil {
|
|
t.Fatalf("createemptydatabase: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cmd := exec.Command(cfg.psqlBinary(), "-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password",
|
|
"-c", "DROP DATABASE IF EXISTS "+quoteIdent(target))
|
|
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
|
_ = cmd.Run()
|
|
})
|
|
|
|
if err := Restore(ctx, cfg, dumpPath, target); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
|
|
got := markerValue(t, cfg, target)
|
|
if got != "gehoert-zu-tenant-a" {
|
|
t.Fatalf("markerwert in wiederhergestellter datenbank = %q, want %q", got, "gehoert-zu-tenant-a")
|
|
}
|
|
}
|
|
|
|
// TestRestore_DoesNotAffectOtherTenant ist Pruefung 2: Wiederherstellung
|
|
// von Tenant A veraendert Tenant B nicht - reales zweites Tenant-DB,
|
|
// Markerwert vor UND nach dem Restore geprueft.
|
|
func TestRestore_DoesNotAffectOtherTenant(t *testing.T) {
|
|
cfg := requireTestConfig(t)
|
|
ctx := context.Background()
|
|
|
|
tenantA := "tenantbackup_test_iso_a"
|
|
tenantB := "tenantbackup_test_iso_b"
|
|
createMarkerDB(t, cfg, tenantA, "wert-a")
|
|
createMarkerDB(t, cfg, tenantB, "wert-b-unveraendert")
|
|
|
|
dumpPath, err := Backup(ctx, cfg, tenantA, time.Now().UTC().Format("20060102T150405Z"))
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
|
|
target := "tenantbackup_test_iso_a_restored"
|
|
if err := CreateEmptyDatabase(ctx, cfg, target); err != nil {
|
|
t.Fatalf("createemptydatabase: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cmd := exec.Command(cfg.psqlBinary(), "-h", cfg.Host, "-p", cfg.Port, "-U", cfg.User, "-d", "postgres", "--no-password",
|
|
"-c", "DROP DATABASE IF EXISTS "+quoteIdent(target))
|
|
cmd.Env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
|
_ = cmd.Run()
|
|
})
|
|
if err := Restore(ctx, cfg, dumpPath, target); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
|
|
// Tenant B, der nie an diesem Vorgang beteiligt war, muss unveraendert sein.
|
|
gotB := markerValue(t, cfg, tenantB)
|
|
if gotB != "wert-b-unveraendert" {
|
|
t.Fatalf("tenant b wurde veraendert: %q, want %q", gotB, "wert-b-unveraendert")
|
|
}
|
|
// Und Tenant Bs Datenbank existiert weiterhin unter ihrem eigenen Namen
|
|
// (kein versehentliches Ueberschreiben/Umbenennen).
|
|
if !databaseExists(t, cfg, tenantB) {
|
|
t.Fatal("tenant-b-datenbank fehlt nach restore von tenant a")
|
|
}
|
|
}
|
|
|
|
// TestVerify_DetectsCorruptedDump ist Nachweis der Vollstaendigkeitspruefung.
|
|
func TestVerify_DetectsCorruptedDump(t *testing.T) {
|
|
cfg := requireTestConfig(t)
|
|
ctx := context.Background()
|
|
|
|
tenantA := "tenantbackup_test_corrupt"
|
|
createMarkerDB(t, cfg, tenantA, "wert")
|
|
|
|
dumpPath, err := Backup(ctx, cfg, tenantA, time.Now().UTC().Format("20060102T150405Z"))
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
if err := Verify(ctx, cfg, dumpPath); err != nil {
|
|
t.Fatalf("verify (unbeschaedigt) haette erfolgreich sein muessen: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(dumpPath, []byte("kein gueltiges pg_dump-custom-archiv"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Verify(ctx, cfg, dumpPath); err == nil {
|
|
t.Fatal("verify haette die beschaedigte sicherung erkennen muessen")
|
|
}
|
|
}
|