package backup import ( "bytes" "log" "path/filepath" "strings" "testing" ) // newSecretGenUnit lays out a recovery unit whose manifest names one resettable secret // (DB_PASSWORD) and one data-key (SECRET_KEY) — the O4 test fixture. func newSecretGenUnit(t *testing.T) (drive string) { t.Helper() drive = filepath.Join(t.TempDir(), "drive") 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 } // TestRestoreGeneratesMissingResettableSecret proves Scenario F: with the data-key recovered but // DB_PASSWORD unrecoverable, the restore PROCEEDS and RecreateStackFromUnit receives a NON-EMPTY // generated DB_PASSWORD (pre-O4 it was simply absent → compose deployed blank → exit 1). Also // proves the generator is consulted for the resettable secret ONLY — never a data-key — and that // the generated VALUE never reaches the logs. // COMPANION red-proof: reverting to the pre-fix behaviour (no generation) fails the non-empty // DB_PASSWORD assertion. func TestRestoreGeneratesMissingResettableSecret(t *testing.T) { const genValue = "generated-secret-value-do-not-log" drive := newSecretGenUnit(t) fake := &fakeRecoveryProvider{ hdd: drive, running: true, secrets: map[string]string{"SECRET_KEY": "deadbeef"}, // DB_PASSWORD unrecoverable } var logBuf bytes.Buffer m := &Manager{logger: log.New(&logBuf, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake} var genCalls []string m.generateSecret = func(stackName, envVar string) (string, bool) { genCalls = append(genCalls, envVar) return genValue, true } if err := m.RestoreFromRecoveryUnit("app"); err != nil { t.Fatalf("restore must proceed for a missing RESETTABLE secret: %v", err) } if fake.gotEnv == nil { t.Fatal("recreate was not called") } if fake.gotEnv["DB_PASSWORD"] != genValue { t.Errorf("DB_PASSWORD = %q, want the generated replacement (pre-O4: absent → blank deploy)", fake.gotEnv["DB_PASSWORD"]) } if fake.gotEnv["SECRET_KEY"] != "deadbeef" { t.Errorf("recovered data-key must pass through verbatim, got %q", fake.gotEnv["SECRET_KEY"]) } if len(genCalls) != 1 || genCalls[0] != "DB_PASSWORD" { t.Errorf("generator consulted for %v, want exactly [DB_PASSWORD] (never data-keys)", genCalls) } logs := logBuf.String() if !strings.Contains(logs, "generated replacement") || !strings.Contains(logs, "DB_PASSWORD") { t.Errorf("WARN must name the reset credential; logs:\n%s", logs) } // Secrets safety: the generated VALUE must never be logged — names only. if strings.Contains(logs, genValue) { t.Errorf("SECRET LEAK: generated value found in logs:\n%s", logs) } } // TestRestoreProceedsWhenNoGenerator proves Scenario G: a missing resettable secret with NO // generator (seam returns ok=false, or no seam wired) still proceeds — env var absent, loud WARN // that the app may fail to start — and never invents a default. func TestRestoreProceedsWhenNoGenerator(t *testing.T) { run := func(t *testing.T, wire bool) { drive := newSecretGenUnit(t) fake := &fakeRecoveryProvider{ hdd: drive, running: true, secrets: map[string]string{"SECRET_KEY": "deadbeef"}, } var logBuf bytes.Buffer m := &Manager{logger: log.New(&logBuf, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake} if wire { m.generateSecret = func(string, string) (string, bool) { return "", false } // no spec (Scenario G) } if err := m.RestoreFromRecoveryUnit("app"); err != nil { t.Fatalf("restore must still proceed: %v", err) } if _, present := fake.gotEnv["DB_PASSWORD"]; present { t.Errorf("no generator → the secret must stay absent, not be invented: %v", fake.gotEnv) } logs := logBuf.String() if !strings.Contains(logs, "no generator") || !strings.Contains(logs, "may fail to start") || !strings.Contains(logs, "DB_PASSWORD") { t.Errorf("upgraded WARN must name the var and the may-fail consequence; logs:\n%s", logs) } } t.Run("generator wired, field has no spec", func(t *testing.T) { run(t, true) }) t.Run("no generator wired at all", func(t *testing.T) { run(t, false) }) } // TestRestoreGenerationNeverReachesDataKeys proves the frozen gate is untouched by O4: a missing // DATA-KEY still refuses fail-closed BEFORE any generation — the generator is never consulted and // the app is never recreated, even with a generator eagerly offering values. func TestRestoreGenerationNeverReachesDataKeys(t *testing.T) { drive := newSecretGenUnit(t) fake := &fakeRecoveryProvider{ hdd: drive, secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing } m := &Manager{logger: log.New(bytes.NewBuffer(nil), "", 0), systemDataPath: filepath.Join(drive, "..", "sys"), stackProvider: fake} var genCalls []string m.generateSecret = func(_, envVar string) (string, bool) { genCalls = append(genCalls, envVar) return "eager-value", true } if err := m.RestoreFromRecoveryUnit("app"); err == nil { t.Fatal("missing data-key must still refuse fail-closed") } if len(genCalls) != 0 { t.Errorf("generator consulted for %v — must be unreachable when the gate refuses", genCalls) } if fake.gotEnv != nil { t.Errorf("recreate must not run on refusal: %v", fake.gotEnv) } }