package config import ( "strings" "testing" "github.com/spf13/viper" ) // validConfig resets the global Viper and sets a complete, well-formed set of the // unconditionally-required keys. Individual cases then mutate one key to assert a // specific failure. func validConfig() { viper.Reset() viper.Set("db-dsn", "postgres://u:p@localhost:5432/db?sslmode=disable") viper.Set("valkey-addr", "localhost:6379") viper.Set("oidc-idp-issuer-url", "https://idp.example.com/realms/main") viper.Set("oidc-sp-client-id", "member-console") viper.Set("base-url", "https://console.example.com") viper.Set("csrf-secret", "0123456789abcdef0123456789abcdef") // exactly 32 bytes viper.Set("deployment-name", DefaultDeploymentName) } // stripeSpec mirrors the ConfigKey declarations internal/integrations/stripe // registers for its ConfigProvider capability (stripe-api-key and // stripe-webhook-secret required together). Tests below pass it as // ValidateStart's integrationSpecs argument instead of importing the real // adapter tree, keeping this package's tests independent of internal/ // integrations. var stripeSpec = []ConfigKey{ {Name: "stripe-api-key", Secret: true, RequiredGroup: "Stripe"}, {Name: "stripe-webhook-secret", Secret: true, RequiredGroup: "Stripe"}, {Name: "stripe-mode", Default: "test"}, } func TestValidateStart_Valid(t *testing.T) { validConfig() if err := ValidateStart(stripeSpec); err != nil { t.Fatalf("expected a complete config to pass, got: %v", err) } } func TestValidateStart_Errors(t *testing.T) { cases := []struct { name string mutate func() want string }{ {"missing db-dsn", func() { viper.Set("db-dsn", "") }, "db-dsn is required"}, {"malformed db-dsn", func() { viper.Set("db-dsn", "mysql://x") }, "db-dsn is not a valid PostgreSQL URL"}, {"missing valkey-addr", func() { viper.Set("valkey-addr", "") }, "valkey-addr is required"}, {"missing issuer", func() { viper.Set("oidc-idp-issuer-url", "") }, "oidc-idp-issuer-url is required"}, {"malformed issuer", func() { viper.Set("oidc-idp-issuer-url", "not-a-url") }, "oidc-idp-issuer-url is not a valid URL"}, {"missing client id", func() { viper.Set("oidc-sp-client-id", "") }, "oidc-sp-client-id is required"}, {"missing base-url", func() { viper.Set("base-url", "") }, "base-url is required"}, {"short csrf", func() { viper.Set("csrf-secret", "tooshort") }, "csrf-secret is invalid"}, {"blank deployment-name", func() { viper.Set("deployment-name", "") }, "deployment-name cannot be blank"}, {"whitespace-only deployment-name", func() { viper.Set("deployment-name", " ") }, "deployment-name cannot be blank"}, {"partial stripe", func() { viper.Set("stripe-api-key", "sk_live_x") }, "Stripe is partially configured"}, {"partial temporal oauth", func() { viper.Set("temporal-host", "localhost:7233") viper.Set("temporal-namespace", "default") viper.Set("temporal-oauth-client-id", "id-only") }, "temporal OAuth is partially configured"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { validConfig() tc.mutate() err := ValidateStart(stripeSpec) if err == nil { t.Fatalf("expected an error containing %q, got nil", tc.want) } if !strings.Contains(err.Error(), tc.want) { t.Fatalf("expected an error containing %q, got: %v", tc.want, err) } }) } } // TestValidateStart_AggregatesAll asserts that an empty config reports every // missing required key in a single aggregated error, not just the first. func TestValidateStart_AggregatesAll(t *testing.T) { viper.Reset() err := ValidateStart(stripeSpec) if err == nil { t.Fatal("expected errors for an empty config, got nil") } for _, want := range []string{ "db-dsn is required", "valkey-addr is required", "oidc-idp-issuer-url is required", "oidc-sp-client-id is required", "base-url is required", "csrf-secret is invalid", "deployment-name cannot be blank", } { if !strings.Contains(err.Error(), want) { t.Errorf("aggregated error missing %q; got: %v", want, err) } } } // enumSpec mirrors the four env-sourced enum ConfigKey declarations covered // by design D8 (schema-hardening): stripe-mode, fedwiki-site-scheme, // discourse-linkage-mode, discourse-auto-create-users (see internal/ // integrations/stripe/stripe.go, internal/integrations/fedwiki/fedwiki.go, // internal/integrations/discourse/discourse.go). Tests below pass it as // ValidateStart's integrationSpecs argument instead of importing the real // adapter trees, mirroring stripeSpec above. var enumSpec = []ConfigKey{ {Name: "stripe-mode", Default: "test", Enum: []string{"test", "live"}}, {Name: "fedwiki-site-scheme", Default: "https", Enum: []string{"http", "https"}}, {Name: "discourse-linkage-mode", Default: "oidc", Enum: []string{"email", "oidc", "discourseconnect"}}, {Name: "discourse-auto-create-users", Default: "false", Enum: []string{"false", "true"}}, } // TestValidateStart_EnumKnobs exercises ValidateStart's enum-membership // check (design D8) through the four real declared enum knobs: a value // outside the declared set fails naming the key, a member value passes, and // an unset key passes (presence is a separate concern). func TestValidateStart_EnumKnobs(t *testing.T) { cases := []struct { name string key string value string // empty means leave the key unset valid bool }{ {"stripe-mode: bad value fails", "stripe-mode", "sandbox", false}, {"stripe-mode: valid value passes", "stripe-mode", "live", true}, {"stripe-mode: unset passes", "stripe-mode", "", true}, {"fedwiki-site-scheme: bad value fails", "fedwiki-site-scheme", "ftp", false}, {"fedwiki-site-scheme: valid value passes", "fedwiki-site-scheme", "http", true}, {"fedwiki-site-scheme: unset passes", "fedwiki-site-scheme", "", true}, {"discourse-linkage-mode: bad value fails", "discourse-linkage-mode", "sso", false}, {"discourse-linkage-mode: valid value passes", "discourse-linkage-mode", "discourseconnect", true}, {"discourse-linkage-mode: unset passes", "discourse-linkage-mode", "", true}, {"discourse-auto-create-users: bad value fails", "discourse-auto-create-users", "yes", false}, {"discourse-auto-create-users: valid value passes", "discourse-auto-create-users", "true", true}, {"discourse-auto-create-users: unset passes", "discourse-auto-create-users", "", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { validConfig() if tc.value != "" { viper.Set(tc.key, tc.value) } err := ValidateStart(enumSpec) if tc.valid { if err != nil { t.Fatalf("expected a valid config to pass, got: %v", err) } return } if err == nil { t.Fatalf("expected an error naming %q, got nil", tc.key) } if !strings.Contains(err.Error(), tc.key) { t.Fatalf("expected error naming %q, got: %v", tc.key, err) } }) } } // TestValidateEnums exercises the generic enum-membership mechanism // directly, independent of any real integration's declared keys — mirrors // TestValidateRequiredGroups below for the sibling check. func TestValidateEnums(t *testing.T) { specs := []ConfigKey{ {Name: "widget-mode", Enum: []string{"a", "b"}}, {Name: "solo-key"}, // no Enum: never flagged } t.Run("unset passes", func(t *testing.T) { viper.Reset() if errs := validateEnums(specs); len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } }) t.Run("member value passes", func(t *testing.T) { viper.Reset() viper.Set("widget-mode", "b") if errs := validateEnums(specs); len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } }) t.Run("non-member value is rejected naming key, value, and allowed set", func(t *testing.T) { viper.Reset() viper.Set("widget-mode", "c") errs := validateEnums(specs) if len(errs) != 1 { t.Fatalf("expected exactly one error, got: %v", errs) } got := errs[0].Error() for _, want := range []string{"widget-mode", `"c"`, "a|b"} { if !strings.Contains(got, want) { t.Errorf("error %q missing %q", got, want) } } }) } // TestValidateRequiredGroups exercises the generic required-together // mechanism directly, independent of any real integration's declared keys — // this is the behavior that replaced validate.go's hardcoded "Conditional: // Stripe" block. func TestValidateRequiredGroups(t *testing.T) { specs := []ConfigKey{ {Name: "widget-key", RequiredGroup: "Widget"}, {Name: "widget-secret", RequiredGroup: "Widget"}, {Name: "solo-key"}, // no group: never flagged } t.Run("neither set is fine", func(t *testing.T) { viper.Reset() if errs := validateRequiredGroups(specs); len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } }) t.Run("both set is fine", func(t *testing.T) { viper.Reset() viper.Set("widget-key", "k") viper.Set("widget-secret", "s") if errs := validateRequiredGroups(specs); len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } }) t.Run("partial is rejected naming both keys", func(t *testing.T) { viper.Reset() viper.Set("widget-key", "k") errs := validateRequiredGroups(specs) if len(errs) != 1 { t.Fatalf("expected exactly one error, got: %v", errs) } got := errs[0].Error() for _, want := range []string{"Widget is partially configured", "widget-key", "widget-secret"} { if !strings.Contains(got, want) { t.Errorf("error %q missing %q", got, want) } } }) }