Files
member-console/internal/config/validate_test.go
T
cgalo5758 8e3c68c6be Make UI surfaces honestly reflect system state
- Add deployment-name branding to titles, mastheads, and OG tags
- Share one grant delivery-state query with lineage across grants
  surfaces
- Show pool status/usage, org owners, and config readiness
- Make billing views projection-aware with recency and sync vocabulary
- Guard FedWiki creation without domains and render route-aware 404s
2026-08-23 01:45:52 -05:00

250 lines
9.2 KiB
Go

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)
}
}
})
}