package stacks import ( "io" "log" "os" "path/filepath" "reflect" "strings" "testing" ) // R-47 (v0.153.0) — the two seams the restore paths need in order to replay a DB dump without the // application racing it: a scoped bring-up, and a persist-without-start. // newR47Manager builds a Manager with one stack whose .felhom.yml declares a locked data-key and a // plain field, so the persist half's locked-field and encryption behaviour is observable. func newR47Manager(t *testing.T) (*Manager, string) { t.Helper() stackDir := filepath.Join(t.TempDir(), "app") if err := os.MkdirAll(stackDir, 0o755); err != nil { t.Fatal(err) } meta := `display_name: App deploy_fields: - env_var: SECRET_KEY type: secret locked_after_deploy: true - env_var: SUBDOMAIN type: subdomain - env_var: TIMEZONE type: text ` if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0o644); err != nil { t.Fatal(err) } m := &Manager{ logger: log.New(io.Discard, "", 0), encKey: []byte("0123456789abcdef0123456789abcdef"), // 32 bytes → AES-256 stacks: map[string]*Stack{ "app": {Name: "app", ComposePath: filepath.Join(stackDir, "docker-compose.yml")}, }, } return m, stackDir } // TestStartStackServicesRefusesEmptyList is the whole reason this function is not a thin wrapper. // `docker compose up -d` with no service arguments is a FULL start — the exact behaviour the DB-only // window exists to avoid. So an empty list must be an ERROR, never a silent pass-through: a caller // that computed zero DB services has, by definition, nothing it may safely start. // // The refusal is also asserted to happen WITHOUT reaching compose: it fires for an unknown stack // too, which proves nothing was executed (a real `up` would need a stack dir and a docker daemon). func TestStartStackServicesRefusesEmptyList(t *testing.T) { m, _ := newR47Manager(t) for _, svcs := range [][]string{nil, {}} { err := m.StartStackServices("app", svcs) if err == nil { t.Fatalf("an empty service list (%v) must be refused — argument-less `up -d` is a FULL start", svcs) } if !strings.Contains(err.Error(), "empty service list") { t.Fatalf("refusal must name the cause, got: %v", err) } } // Unknown stack: refused at the lookup, still without touching compose. if err := m.StartStackServices("nope", []string{"db"}); err == nil { t.Fatal("an unknown stack must be refused") } } // TestPersistUnitRedeployConfigPersistsWithoutStarting is the split's contract. RedeployFromEnv used // to be persist+start in one call, which is why the local restore path could not put the DB-only // window between them. This asserts the persist half is COMPLETE on its own — app.yaml written with // the deployed marker, the locked field recorded, the secret encrypted at rest and decryptable, and // the in-memory stack flipped to deployed — so RedeployFromEnv's public behaviour is unchanged by // being expressed as this function plus the untouched up-and-report tail. func TestPersistUnitRedeployConfigPersistsWithoutStarting(t *testing.T) { m, stackDir := newR47Manager(t) const secret = "s3cr3t-data-key-value" env := map[string]string{"SECRET_KEY": secret, "SUBDOMAIN": "app", "TIMEZONE": "Europe/Budapest", "HDD_PATH": "/mnt/drv"} if err := m.PersistUnitRedeployConfig("app", env); err != nil { t.Fatalf("PersistUnitRedeployConfig: %v", err) } cfgPath := filepath.Join(stackDir, "app.yaml") raw, err := os.ReadFile(cfgPath) if err != nil { t.Fatalf("app.yaml was not written — the persist half is incomplete: %v", err) } // Secrets safety: the plaintext must not be at rest in app.yaml. if strings.Contains(string(raw), secret) { t.Fatal("SECRET LEAK: the secret value is stored in plaintext in app.yaml") } got := LoadAppConfigDecrypted(stackDir, m.encKey) if got == nil { t.Fatal("app.yaml does not load back") } if !got.Deployed || got.DeployedAt == "" { t.Errorf("app must be marked deployed with a timestamp, got deployed=%v at=%q", got.Deployed, got.DeployedAt) } if got.Env["SECRET_KEY"] != secret { t.Errorf("SECRET_KEY did not round-trip through encryption, got %q", got.Env["SECRET_KEY"]) } if got.Env["SUBDOMAIN"] != "app" || got.Env["HDD_PATH"] != "/mnt/drv" { t.Errorf("non-secret env did not persist verbatim: %v", got.Env) } // Secrets and subdomains are implicitly locked-after-deploy; a plain text field is not. Recording // exactly those is part of the persist half, and losing it in the split would silently unlock a // data-key field on the next config edit. if !reflect.DeepEqual(got.LockedFields, []string{"SECRET_KEY", "SUBDOMAIN"}) { t.Errorf("locked fields = %v, want [SECRET_KEY SUBDOMAIN] (TIMEZONE must NOT be locked)", got.LockedFields) } // In-memory state must agree, because StartStack (the caller's next step) reads it. s, ok := m.GetStack("app") if !ok || !s.Deployed { t.Fatalf("in-memory stack not marked deployed (ok=%v), so the follow-up start would treat it as undeployed", ok) } if s.AppConfig == nil || s.AppConfig.Env["SECRET_KEY"] == "" { t.Error("in-memory AppConfig not populated by the persist half") } } // TestPersistUnitRedeployConfigRejectsUnknownStack keeps the failure direction the same as the // unsplit function's: an unknown stack is an error, not a silently-created app.yaml somewhere. func TestPersistUnitRedeployConfigRejectsUnknownStack(t *testing.T) { m, _ := newR47Manager(t) if err := m.PersistUnitRedeployConfig("nope", map[string]string{"A": "b"}); err == nil { t.Fatal("an unknown stack must be refused") } }