package stacks import "testing" // TestPortableSecretEnvVars pins the D5 ruling (operator, 2026-07-30) about WHICH secrets may travel // on the customer's backup drive: `type: secret` travels, `type: password` does not, minus the // nonPortableSecrets register. // // This is a security boundary, so the test asserts the CONSEQUENCE in both directions — what travels // and, more importantly, what must not. A change that widens the class fails here. func TestPortableSecretEnvVars(t *testing.T) { t.Run("type=secret travels, type=password does not", func(t *testing.T) { meta := &Metadata{Slug: "paperless-ngx", DeployFields: []DeployField{ {EnvVar: "DB_PASSWORD", Type: "secret"}, {EnvVar: "PAPERLESS_SECRET_KEY", Type: "secret"}, {EnvVar: "PAPERLESS_ADMIN_PASSWORD", Type: "password"}, {EnvVar: "SUBDOMAIN", Type: "subdomain"}, }} got := PortableSecretEnvVars(meta) want := []string{"DB_PASSWORD", "PAPERLESS_SECRET_KEY"} if len(got) != len(want) { t.Fatalf("portable = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { // order must be stable metadata order (checksum-skip depends on it) t.Errorf("portable[%d] = %q, want %q (got %v)", i, got[i], want[i], got) } } }) t.Run("an internet-reachable admin login is WITHHELD even when typed secret", func(t *testing.T) { meta := &Metadata{Slug: "vaultwarden", DeployFields: []DeployField{ {EnvVar: "ADMIN_TOKEN", Type: "secret"}, }} if got := PortableSecretEnvVars(meta); len(got) != 0 { t.Errorf("vaultwarden/ADMIN_TOKEN must NOT travel (it gates the public /admin panel), got %v", got) } }) t.Run("the register is scoped to its app, not the bare env-var name", func(t *testing.T) { // Another app's ADMIN_TOKEN is not vaultwarden's and must not be caught by the register. meta := &Metadata{Slug: "some-other-app", DeployFields: []DeployField{ {EnvVar: "ADMIN_TOKEN", Type: "secret"}, }} if got := PortableSecretEnvVars(meta); len(got) != 1 || got[0] != "ADMIN_TOKEN" { t.Errorf("register must be slug-scoped, got %v", got) } }) t.Run("no deploy fields — nothing travels", func(t *testing.T) { if got := PortableSecretEnvVars(&Metadata{Slug: "x"}); len(got) != 0 { t.Errorf("want empty, got %v", got) } }) t.Run("portable is a SUBSET of SensitiveEnvVars", func(t *testing.T) { // The unit's app.yaml splits env into non-secret + portable using two different helpers; if // PortableSecretEnvVars ever returned a name SensitiveEnvVars does not, that name would land in // NonSecretEnv as well and the disjointness GetStackRecoveryInfo relies on would break. meta := &Metadata{Slug: "nextcloud", DeployFields: []DeployField{ {EnvVar: "DB_PASSWORD", Type: "secret"}, {EnvVar: "MYSQL_ROOT_PASSWORD", Type: "secret"}, {EnvVar: "NEXTCLOUD_ADMIN_PASSWORD", Type: "password"}, {EnvVar: "DOMAIN", Type: "domain"}, }} sensitive := make(map[string]bool) for _, n := range SensitiveEnvVars(meta) { sensitive[n] = true } for _, n := range PortableSecretEnvVars(meta) { if !sensitive[n] { t.Errorf("%q is portable but not sensitive — it would also land in NonSecretEnv", n) } } }) }