From e02292aa1ae35ae9428cc8d8edffe40442fc331c Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 13 Jun 2026 11:17:08 +0200 Subject: [PATCH] test: Phase 2b restore orchestration coverage + nil-safe isDebug Adds an in-process orchestration test for RestoreFromRecoveryUnit: success path calls recreate with non-secret env + recovered secrets merged; data-key-missing path is REFUSED and recreate is never called. Makes Manager.isDebug nil-safe (behavior-neutral in prod; cfg is always set) so the gate/orchestration are testable. Co-Authored-By: Claude Opus 4.8 (1M context) --- controller/internal/backup/backup.go | 2 +- .../internal/backup/recovery_unit_test.go | 21 ++++-- .../internal/backup/restore_unit_test.go | 67 ++++++++++++++++++- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 6f58c03..26a1584 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -594,7 +594,7 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus { // isDebug returns true if logging level is "debug". func (m *Manager) isDebug() bool { - return m.cfg.Logging.Level == "debug" + return m.cfg != nil && m.cfg.Logging.Level == "debug" } func dbNames(dbs []DiscoveredDB) string { diff --git a/controller/internal/backup/recovery_unit_test.go b/controller/internal/backup/recovery_unit_test.go index 1f993f0..6e3f0b3 100644 --- a/controller/internal/backup/recovery_unit_test.go +++ b/controller/internal/backup/recovery_unit_test.go @@ -11,10 +11,14 @@ import ( "testing" ) -// fakeRecoveryProvider is a minimal StackDataProvider for the capture test. +// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests. type fakeRecoveryProvider struct { - info RecoveryInfo - hdd string + info RecoveryInfo + hdd string + secrets map[string]string // returned by RecoverStackSecrets + gotEnv map[string]string // captured by RecreateStackFromUnit + running bool // returned by RefreshAndIsRunning + stopped bool } func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) { @@ -24,14 +28,17 @@ func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { retur func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil } func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd } func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil } -func (f *fakeRecoveryProvider) StopStack(string) error { return nil } +func (f *fakeRecoveryProvider) StopStack(string) error { f.stopped = true; return nil } func (f *fakeRecoveryProvider) StartStack(string) error { return nil } -func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return false } +func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running } func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return f.info, true } -func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } -func (f *fakeRecoveryProvider) RecreateStackFromUnit(string, string, map[string]string) error { +func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string { + return f.secrets +} +func (f *fakeRecoveryProvider) RecreateStackFromUnit(_, _ string, fullEnv map[string]string) error { + f.gotEnv = fullEnv return nil } diff --git a/controller/internal/backup/restore_unit_test.go b/controller/internal/backup/restore_unit_test.go index 7f2883f..ef1669c 100644 --- a/controller/internal/backup/restore_unit_test.go +++ b/controller/internal/backup/restore_unit_test.go @@ -1,6 +1,71 @@ package backup -import "testing" +import ( + "io" + "log" + "path/filepath" + "testing" +) + +// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest → +// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the +// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the +// restore is REFUSED and recreate is never called. +func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) { + newUnit := func(t *testing.T) (drive string) { + tmp := t.TempDir() + drive = filepath.Join(tmp, "drive") + // stripped (secret-free) app.yaml in the unit + mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"), + "deployed: true\nenv:\n SUBDOMAIN: trips\n") + man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v", + SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"}, DataKeyEnvVars: []string{"SECRET_KEY"}} + if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil { + t.Fatal(err) + } + return drive + } + + t.Run("success — recreate called with merged env", func(t *testing.T) { + drive := newUnit(t) + fake := &fakeRecoveryProvider{ + hdd: drive, + running: true, // so the post-restore health wait returns promptly + secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}, + } + m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), + stackProvider: fake} + if err := m.RestoreFromRecoveryUnit("app"); err != nil { + t.Fatalf("restore: %v", err) + } + if fake.gotEnv == nil { + t.Fatal("recreate was not called") + } + if fake.gotEnv["SUBDOMAIN"] != "trips" || fake.gotEnv["DB_PASSWORD"] != "pw" || fake.gotEnv["SECRET_KEY"] != "deadbeef" { + t.Errorf("recreate got wrong env: %v", fake.gotEnv) + } + if !fake.stopped { + t.Errorf("app should be stopped before restore") + } + }) + + t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) { + drive := newUnit(t) + fake := &fakeRecoveryProvider{ + hdd: drive, + secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing + } + m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), + stackProvider: fake} + err := m.RestoreFromRecoveryUnit("app") + if err == nil { + t.Fatal("expected fail-closed refusal, got nil") + } + if fake.gotEnv != nil { + t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv) + } + }) +} // TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation. func TestReconcileRestoreSecrets(t *testing.T) {