Files
felhom-controller/controller/internal/backup/restore_unit_test.go
T
admin 7863e62f29 v0.54.0: Phase 2b — restore-from-recovery-unit + fail-closed data-key gate
Restore recreates an app from its on-drive unit + the guest's own secrets,
regenerating nothing. reconcileRestoreSecrets (pure, unit-tested) merges the unit's
non-secret env with secrets recovered from the live app.yaml and FAILS CLOSED if a
data-encrypting key is unrecoverable (refuse — a PBS whole-guest restore is needed —
rather than regenerate and corrupt). Resettable secrets missing → warn + proceed.

- backup: RestoreFromRecoveryUnit (manifest -> recover secrets -> gate -> restore
  volumes -> recreate definition + redeploy w/ re-pull); falls back to volume-only.
- seams: RecoverStackSecrets/RecreateStackFromUnit (adapter +encKey),
  stacks.RedeployFromEnv. Wired into /backup/restore.
- tests: gate (refuse/proceed/verbatim) + data_key parsing.

Gate + reconcile + data_key parsing unit-tested; capture live-validated (v0.53.1).
Full readable-data e2e vs AdventureLog needs the auth-gated dashboard restore — pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:12:43 +02:00

63 lines
2.5 KiB
Go

package backup
import "testing"
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
func TestReconcileRestoreSecrets(t *testing.T) {
nonSecret := map[string]string{"SUBDOMAIN": "trips", "DOMAIN": "demo-felhom.eu"}
t.Run("all recovered, no data_key — full env, no error", func(t *testing.T) {
recovered := map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"}
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
[]string{"DB_PASSWORD", "SECRET_KEY"}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(missing) != 0 {
t.Errorf("missing: %v", missing)
}
// Non-secret + both secrets present, and recovered values used VERBATIM (regenerate nothing).
if full["SUBDOMAIN"] != "trips" || full["DB_PASSWORD"] != "pw" || full["SECRET_KEY"] != "deadbeef" {
t.Errorf("full env wrong: %v", full)
}
})
t.Run("data_key missing — FAIL CLOSED (refuse)", func(t *testing.T) {
recovered := map[string]string{"DB_PASSWORD": "pw"} // SECRET_KEY (a data_key) is gone
full, _, err := reconcileRestoreSecrets(nonSecret, recovered,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err == nil {
t.Fatal("expected fail-closed error for missing data-encrypting key, got nil")
}
if full != nil {
t.Errorf("full env should be nil on refusal, got %v", full)
}
})
t.Run("data_key empty value — FAIL CLOSED", func(t *testing.T) {
recovered := map[string]string{"SECRET_KEY": ""} // present but empty == unrecoverable
_, _, err := reconcileRestoreSecrets(nonSecret, recovered, []string{"SECRET_KEY"}, []string{"SECRET_KEY"})
if err == nil {
t.Fatal("empty data-key value must fail closed")
}
})
t.Run("resettable secret missing — proceed with warning", func(t *testing.T) {
recovered := map[string]string{"SECRET_KEY": "deadbeef"} // data_key ok; DB_PASSWORD missing
full, missing, err := reconcileRestoreSecrets(nonSecret, recovered,
[]string{"DB_PASSWORD", "SECRET_KEY"}, []string{"SECRET_KEY"})
if err != nil {
t.Fatalf("a missing resettable secret must NOT fail closed: %v", err)
}
if len(missing) != 1 || missing[0] != "DB_PASSWORD" {
t.Errorf("missing should be [DB_PASSWORD], got %v", missing)
}
if full["SECRET_KEY"] != "deadbeef" {
t.Errorf("data-key should be preserved verbatim: %v", full)
}
if _, present := full["DB_PASSWORD"]; present {
t.Errorf("missing resettable secret should be absent, not regenerated")
}
})
}