7863e62f29
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>
63 lines
2.5 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|