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) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:17:08 +02:00
parent 7863e62f29
commit e02292aa1a
3 changed files with 81 additions and 9 deletions
@@ -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) {