// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package config import ( "strings" "testing" "time" "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("valkey-password", "test-store-password") 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("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 valkey-password", func() { viper.Set("valkey-password", "") }, "valkey-password is required"}, {"base-url with a scheme a browser cannot use", func() { viper.Set("base-url", "ftp://console.example.com") }, "base-url must be http or https"}, {"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"}, {"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_MalformedDSNDoesNotLeak covers task 1.4 (security-audit- // remediation-2, design D1): a malformed db-dsn's validation error names the // key and the expected shape only, never the configured value, which may // carry a password baked into the URL. func TestValidateStart_MalformedDSNDoesNotLeak(t *testing.T) { validConfig() viper.Set("db-dsn", "mysql://secret-value@h/db") err := ValidateStart(stripeSpec) if err == nil { t.Fatal("expected an error for a malformed db-dsn, got nil") } if !strings.Contains(err.Error(), "db-dsn is not a valid PostgreSQL URL") { t.Fatalf("expected the error to name db-dsn and the expected shape, got: %v", err) } if strings.Contains(err.Error(), "secret-value") { t.Fatalf("error leaked the configured value: %v", 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", "valkey-password is required", "oidc-idp-issuer-url is required", "oidc-sp-client-id is required", "base-url is required", "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) } }) } } // TestValidateTypes exercises the generic per-type parse mechanism // directly, independent of any real integration's declared keys — mirrors // TestValidateRequiredGroups below for the sibling check. The enum case // covers what the former, now-folded validateEnums tested on its own // (design D2: "validateEnums folds into validateTypes"). func TestValidateTypes(t *testing.T) { specs := []ConfigKey{ {Name: "widget-mode", Enum: []string{"a", "b"}}, {Name: "widget-timeout", Default: time.Hour}, {Name: "solo-key"}, // no Enum, string-inferred: never flagged by shape alone } t.Run("unset passes", func(t *testing.T) { viper.Reset() if errs := validateTypes(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 := validateTypes(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 := validateTypes(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) } } }) t.Run("a duration key rejects a word naming the key and the parser's sentence", func(t *testing.T) { viper.Reset() viper.Set("widget-timeout", "hello") errs := validateTypes(specs) if len(errs) != 1 { t.Fatalf("expected exactly one error, got: %v", errs) } got := errs[0].Error() for _, want := range []string{"widget-timeout", `value "hello" is not a duration; for example 30m or 1h30m`} { if !strings.Contains(got, want) { t.Errorf("error %q missing %q", got, want) } } }) t.Run("a parseable duration value passes", func(t *testing.T) { viper.Reset() viper.Set("widget-timeout", "45m") if errs := validateTypes(specs); len(errs) != 0 { t.Fatalf("expected no errors, got: %v", errs) } }) } // TestValidateStart_DurationTypeCatchesTheEnvironment covers the scenario // the maintainer's report described directly through ValidateStart (spec // integration-config-declaration, "A duration key refuses a word at save // and at boot"): an environment value of "hello" for a duration key fails // boot with the parser's own sentence, not a silently-zeroed interval. func TestValidateStart_DurationTypeCatchesTheEnvironment(t *testing.T) { durationSpec := []ConfigKey{ {Name: "widget-sync-interval", Default: time.Hour}, } t.Run("a word fails with the parser's sentence", func(t *testing.T) { validConfig() viper.Set("widget-sync-interval", "hello") err := ValidateStart(durationSpec) if err == nil || !strings.Contains(err.Error(), `value "hello" is not a duration; for example 30m or 1h30m`) { t.Fatalf("want the parser's sentence, got: %v", err) } }) t.Run("empty (unset) passes", func(t *testing.T) { validConfig() if err := ValidateStart(durationSpec); err != nil { t.Fatalf("expected a complete config to pass, got: %v", err) } }) } // 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) } } }) }