Files
felhom-controller/controller/internal/stacks/deploy_secretgen_test.go
T
admin a52851e79e fix(backup): O4 — generate a replacement for unrecoverable resettable secrets on restore
The proceed-path for a missing RESETTABLE secret redeployed the app with the
secret blank (compose "Defaulting to a blank string" → exit 1, live-hit in the
2026-07-04 drill Phase 5). Now the restore generates a fresh credential instead:

- stacks.Manager.GenerateSecretForField: replacement value from the field's
  catalog generate spec via the deploy flow's generateValue (no logic copied);
  refuses data-keys (defense-in-depth), spec-less and non-secret fields.
- backup.Manager.SetSecretGenerator seam (wired in main.go), consulted in
  RestoreFromRecoveryUnit AFTER the untouched fail-closed gate, for missing
  names NOT in DataKeyEnvVars. The generated value rides fullEnv into
  RecreateStackFromUnit → RedeployFromEnv → SaveAppConfig, so it persists
  encrypted in the guest app.yaml and round-trips on the next backup/restore
  (no second write path). reconcileRestoreSecrets stays pure and untouched.
- WARNs now discriminate: "generated replacement for X (credential was reset)"
  vs "X unrecoverable and has no generator — app may fail to start". Values are
  never logged (asserted in test).
- Residual case (documented, not pretended away): if a restored volume tar
  carries the OLD internal credential hash, the app may still fail auth until a
  manual in-DB reset — generation fully fixes only the fresh-init case.

Companion red-proof: pre-fix behaviour (generation skipped) fails
TestRestoreGeneratesMissingResettableSecret on the non-empty DB_PASSWORD
assertion (verified, reverted). Data-key gate proven unreachable by generation
in TestRestoreGenerationNeverReachesDataKeys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 11:52:04 +02:00

90 lines
2.8 KiB
Go

package stacks
import (
"io"
"log"
"os"
"path/filepath"
"regexp"
"testing"
)
// newSecretGenManager builds a Manager with one stack whose .felhom.yml declares the O4 test
// fields: a generatable resettable secret, a data-key, and a spec-less secret.
func newSecretGenManager(t *testing.T) *Manager {
t.Helper()
stackDir := filepath.Join(t.TempDir(), "app")
if err := os.MkdirAll(stackDir, 0755); err != nil {
t.Fatal(err)
}
meta := `display_name: App
deploy_fields:
- env_var: DB_PASSWORD
type: secret
generate: "password:24"
- env_var: SECRET_KEY
type: secret
generate: "hex:32"
data_key: true
- env_var: ADMIN_TOKEN
type: secret
- env_var: SUBDOMAIN
type: subdomain
default: app
`
if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0644); err != nil {
t.Fatal(err)
}
return &Manager{
logger: log.New(io.Discard, "", 0),
stacks: map[string]*Stack{
"app": {Name: "app", ComposePath: filepath.Join(stackDir, "docker-compose.yml")},
},
}
}
// TestGenerateSecretForField covers the O4 generator seam's contract: spec-conformant values for
// resettable secrets, and REFUSAL for data-keys (frozen fail-closed territory), spec-less fields,
// non-secret fields, and unknown stacks/vars.
func TestGenerateSecretForField(t *testing.T) {
m := newSecretGenManager(t)
t.Run("resettable secret with spec → spec-conformant value", func(t *testing.T) {
v, ok := m.GenerateSecretForField("app", "DB_PASSWORD")
if !ok {
t.Fatal("expected generation for DB_PASSWORD (generate: password:24)")
}
if len(v) != 24 || !regexp.MustCompile(`^[A-Za-z0-9]+$`).MatchString(v) {
t.Errorf("value does not conform to password:24 (len=%d)", len(v))
}
// Distinct per call (crypto/rand-backed, not a constant).
if v2, _ := m.GenerateSecretForField("app", "DB_PASSWORD"); v2 == v {
t.Error("two generations returned the same value")
}
})
t.Run("data-key → REFUSED even with a generate spec", func(t *testing.T) {
if v, ok := m.GenerateSecretForField("app", "SECRET_KEY"); ok || v != "" {
t.Error("a data-encrypting key must NEVER be generated")
}
})
t.Run("no generate spec → refused", func(t *testing.T) {
if _, ok := m.GenerateSecretForField("app", "ADMIN_TOKEN"); ok {
t.Error("spec-less secret must not be generated (Scenario G: proceed-with-warn instead)")
}
})
t.Run("non-secret field / unknown var / unknown stack → refused", func(t *testing.T) {
if _, ok := m.GenerateSecretForField("app", "SUBDOMAIN"); ok {
t.Error("non-secret field must not be generated")
}
if _, ok := m.GenerateSecretForField("app", "NOPE"); ok {
t.Error("unknown env var must not be generated")
}
if _, ok := m.GenerateSecretForField("ghost", "DB_PASSWORD"); ok {
t.Error("unknown stack must not be generated")
}
})
}