package stacks import ( "os" "path/filepath" "strings" "testing" ) // Regression tests for the deploy-lifecycle slice (audit/2026-06-13): // - H10: SaveAppConfig must FAIL CLOSED on an encryption error (never persist // a sensitive value in plaintext). // - CTRL-T2-1: the durable record must read as NOT deployed until a deploy // actually completes, so a crash during the image-pull window leaves a // redeployable stack rather than a ghost-deployed one. // TestSaveAppConfigFailsClosedOnEncryptError — H10. Originated as a failing // reconcile test; now a permanent guard. A bad-length encKey makes crypto.Encrypt // fail; SaveAppConfig must return an error and write NO plaintext secret. func TestSaveAppConfigFailsClosedOnEncryptError(t *testing.T) { dir := t.TempDir() const secret = "supersecret-pw-do-not-leak" cfg := &AppConfig{Deployed: true, Env: map[string]string{"DB_PASSWORD": secret}} badKey := []byte("short") // non-nil, invalid AES key length → crypto.Encrypt errors err := SaveAppConfig(dir, cfg, badKey, []string{"DB_PASSWORD"}) if err == nil { t.Fatalf("H10: SaveAppConfig returned nil on an encrypt failure — expected a fail-closed error") } // No app.yaml should have been written; even if one was, it must not contain // the plaintext secret. if data, readErr := os.ReadFile(filepath.Join(dir, "app.yaml")); readErr == nil { if strings.Contains(string(data), secret) { t.Fatalf("H10: app.yaml contains the secret in plaintext after an encrypt failure:\n%s", data) } } } // TestSaveAppConfigEncryptsWithGoodKey — companion: a valid key encrypts the // sensitive var (it must not appear in plaintext) and the save succeeds. func TestSaveAppConfigEncryptsWithGoodKey(t *testing.T) { dir := t.TempDir() const secret = "supersecret-pw" key := make([]byte, 32) // valid AES-256 key cfg := &AppConfig{Deployed: true, Env: map[string]string{"DB_PASSWORD": secret}} if err := SaveAppConfig(dir, cfg, key, []string{"DB_PASSWORD"}); err != nil { t.Fatalf("SaveAppConfig with a valid key failed: %v", err) } data, err := os.ReadFile(filepath.Join(dir, "app.yaml")) if err != nil { t.Fatalf("reading app.yaml: %v", err) } if strings.Contains(string(data), secret) { t.Fatalf("sensitive value was written in plaintext despite a valid key:\n%s", data) } } // TestTransitionalDeployStateReadsNotDeployed — CTRL-T2-1. DeployStack now // writes the env with Deployed:false before launching compose, and flips it to // Deployed:true only after `up -d` succeeds. This locks the durable-state // semantics ScanStacks relies on: a stack persisted in the transitional state // (the on-disk state left by a crash mid-pull) MUST read as not-deployed, so the // customer can redeploy. The full crash-window is integration-level (needs a // real compose); see the audit's manual repro. func TestTransitionalDeployStateReadsNotDeployed(t *testing.T) { key := make([]byte, 32) sensitive := []string{"DB_PASSWORD"} // Transitional state DeployStack writes BEFORE compose succeeds. transitional := t.TempDir() if err := SaveAppConfig(transitional, &AppConfig{ Deployed: false, DeployedAt: "2026-06-13T00:00:00Z", Env: map[string]string{"DB_PASSWORD": "x"}, }, key, sensitive); err != nil { t.Fatalf("saving transitional config: %v", err) } got := LoadAppConfig(transitional) if got == nil { t.Fatal("LoadAppConfig returned nil for the transitional state") } // This is exactly the expression ScanStacks uses: deployed := cfg != nil && cfg.Deployed if got.Deployed { t.Fatalf("CTRL-T2-1: a deploy that did not complete reads as Deployed=true — ghost-deployed; "+ "DeployStack must persist Deployed:false until compose succeeds") } // Completed state (what runComposeDeploy writes on success) reads deployed. completed := t.TempDir() if err := SaveAppConfig(completed, &AppConfig{ Deployed: true, DeployedAt: "2026-06-13T00:00:00Z", Env: map[string]string{"DB_PASSWORD": "x"}, }, key, sensitive); err != nil { t.Fatalf("saving completed config: %v", err) } if c := LoadAppConfig(completed); c == nil || !c.Deployed { t.Fatalf("CTRL-T2-1: a completed deploy must read as Deployed=true, got %+v", c) } }