Files
member-console/internal/config/validate_test.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- Rotate the session token at the OIDC callback and restore the full
  lifetime; cap pre-auth sessions at 15 minutes and write no session
  for bare anonymous requests
- Treat db-dsn as a secret: accept db-dsn-file, log only host, port,
  database and user, and never echo a malformed DSN in an error
- Guard the logout callback with a state cookie so a forged visit
  cannot end a live session
- Collapse FedWiki site actions on a foreign tenant's domain to the
  not-found answer, as for a domain that does not exist
2026-09-09 20:53:31 -05:00

326 lines
12 KiB
Go

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