Files
member-console/internal/config/overlay_test.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

219 lines
9.0 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"
)
// overlaySpecs is a synthetic ConfigSpec covering every declared type the
// overlay parses, mirroring how adapters declare keys without importing the
// real adapter trees. it-url has no default (mirroring fedwiki-farm-api-url
// and discourse-base-url: a URL key with nothing to infer from, so Type
// must be declared); it-cooldown mirrors fedwiki-swap-cooldown (zero
// disables it, so no Positive); it-sync-interval mirrors
// fedwiki-sync-interval/discourse-sync-interval (Positive: true, design D6).
var overlaySpecs = []ConfigKey{
{Name: "it-color", Default: "blue", Usage: "a string key"},
{Name: "it-domains", Default: []string(nil), Usage: "a list key"},
{Name: "it-enabled", Default: false, Usage: "a bool key"},
{Name: "it-limit", Default: 7, Usage: "an int key"},
{Name: "it-scheme", Default: "https", Enum: []string{"http", "https"}, Usage: "an enum key"},
{Name: "it-token", Secret: true, Usage: "a secret key"},
{Name: "it-url", Type: TypeURL, Usage: "a url key with no default"},
{Name: "it-cooldown", Default: 30 * 24 * time.Hour, Usage: "a duration key where zero disables"},
{Name: "it-sync-interval", Default: time.Hour, Positive: true, Usage: "a positive duration key"},
{Name: "it-positive-limit", Default: 7, Positive: true, Usage: "a positive int key"},
}
// resetOverlay clears the global viper and the boot snapshot, then
// registers each spec's declared default the way flag binding does in
// production (BindPFlags makes Get fall back to the flag default, which is
// itself registered from ConfigSpec).
func resetOverlay(t *testing.T) {
t.Helper()
viper.Reset()
bootEffective = map[string]Effective{}
for _, s := range overlaySpecs {
if s.Default != nil {
viper.SetDefault(s.Name, s.Default)
}
}
}
func TestCoerceOverride(t *testing.T) {
specFor := func(name string) ConfigKey {
for _, s := range overlaySpecs {
if s.Name == name {
return s
}
}
t.Fatalf("no spec %q", name)
return ConfigKey{}
}
cases := []struct {
name string
key string
raw string
want any
wantErr string
}{
{name: "string passthrough", key: "it-color", raw: "green", want: "green"},
{name: "list split and trimmed", key: "it-domains", raw: " a.example, b.example ,", want: []string{"a.example", "b.example"}},
{name: "bool parsed", key: "it-enabled", raw: "true", want: true},
{name: "bool invalid", key: "it-enabled", raw: "banana", wantErr: "not a boolean"},
{name: "int parsed", key: "it-limit", raw: "42", want: 42},
{name: "int invalid", key: "it-limit", raw: "many", wantErr: "not an integer"},
{name: "enum member", key: "it-scheme", raw: "http", want: "http"},
{name: "enum non-member", key: "it-scheme", raw: "gopher", wantErr: "not one of http|https"},
{name: "url accepted", key: "it-url", raw: "https://admin.wiki.example.com", want: "https://admin.wiki.example.com"},
{name: "url refuses a word", key: "it-url", raw: "hello", wantErr: `value "hello" is not an absolute http or https URL`},
{name: "url refuses a scheme-only value with no host", key: "it-url", raw: "https://", wantErr: "not an absolute http or https URL"},
{name: "duration accepted", key: "it-cooldown", raw: "1h30m", want: 90 * time.Minute},
{name: "duration refuses a word", key: "it-cooldown", raw: "hello", wantErr: `value "hello" is not a duration; for example 30m or 1h30m`},
{name: "duration refuses a bare negative-looking number with no unit", key: "it-cooldown", raw: "-100000", wantErr: `value "-100000" is not a duration; for example 30m or 1h30m`},
{name: "duration refuses a negative value", key: "it-cooldown", raw: "-1h", wantErr: `value "-1h" is a negative duration`},
{name: "duration zero accepted when the key is not Positive (cooldown: 0 disables)", key: "it-cooldown", raw: "0", want: time.Duration(0)},
{name: "duration zero refused when the key declares Positive", key: "it-sync-interval", raw: "0", wantErr: `value "0" must be greater than zero`},
{name: "positive duration accepts a real interval", key: "it-sync-interval", raw: "30m", want: 30 * time.Minute},
{name: "positive int zero refused", key: "it-positive-limit", raw: "0", wantErr: `value "0" must be greater than zero`},
{name: "positive int accepts a real value", key: "it-positive-limit", raw: "5", want: 5},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := CoerceOverride(specFor(tc.key), tc.raw)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("want error containing %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
switch want := tc.want.(type) {
case []string:
gotSlice, ok := got.([]string)
if !ok || strings.Join(gotSlice, ",") != strings.Join(want, ",") {
t.Fatalf("want %v, got %v", want, got)
}
default:
if got != tc.want {
t.Fatalf("want %v, got %v", tc.want, got)
}
}
})
}
}
func TestApplyOverlayPrecedence(t *testing.T) {
t.Run("override wins over environment", func(t *testing.T) {
resetOverlay(t)
viper.Set("it-color", "green") // any non-default boot source
// A second ApplyOverlay viper.Set replaces it; assert via resolution.
if err := ApplyOverlay(overlaySpecs, []Override{{Key: "it-color", Value: "red"}}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := viper.GetString("it-color"); got != "red" {
t.Fatalf("want red, got %q", got)
}
if eff := bootEffective["it-color"]; eff.Source != SourceOverride || eff.Value != "red" {
t.Fatalf("want override/red, got %+v", eff)
}
})
t.Run("environment wins over default", func(t *testing.T) {
resetOverlay(t)
viper.Set("it-color", "green")
if err := ApplyOverlay(overlaySpecs, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if eff := bootEffective["it-color"]; eff.Source != SourceEnvironment || eff.Value != "green" {
t.Fatalf("want environment/green, got %+v", eff)
}
})
t.Run("default when nothing set", func(t *testing.T) {
resetOverlay(t)
if err := ApplyOverlay(overlaySpecs, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if eff := bootEffective["it-color"]; eff.Source != SourceDefault || eff.Value != "blue" {
t.Fatalf("want default/blue, got %+v", eff)
}
})
}
func TestApplyOverlayListKeyReadsAsList(t *testing.T) {
resetOverlay(t)
if err := ApplyOverlay(overlaySpecs, []Override{{Key: "it-domains", Value: "a.example,b.example"}}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := viper.GetStringSlice("it-domains")
if len(got) != 2 || got[0] != "a.example" || got[1] != "b.example" {
t.Fatalf("want [a.example b.example], got %v", got)
}
if eff := bootEffective["it-domains"]; eff.Value != "a.example,b.example" {
t.Fatalf("snapshot value: want comma-joined form, got %+v", eff)
}
}
func TestApplyOverlayUnknownKeySkipped(t *testing.T) {
resetOverlay(t)
if err := ApplyOverlay(overlaySpecs, []Override{{Key: "it-retired", Value: "x"}}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if viper.IsSet("it-retired") {
t.Fatal("unknown override must not be applied")
}
if _, ok := bootEffective["it-retired"]; ok {
t.Fatal("unknown override must not enter the snapshot")
}
}
func TestApplyOverlaySecretRejected(t *testing.T) {
resetOverlay(t)
err := ApplyOverlay(overlaySpecs, []Override{{Key: "it-token", Value: "hunter2"}})
if err == nil || !strings.Contains(err.Error(), "declared secret") {
t.Fatalf("want declared-secret error, got %v", err)
}
if !strings.Contains(err.Error(), "clear it: member-console config clear it-token") {
t.Fatalf("want remediation in error, got %v", err)
}
if viper.IsSet("it-token") {
t.Fatal("secret override must not be applied")
}
}
func TestApplyOverlayCorruptValueFailsActionably(t *testing.T) {
resetOverlay(t)
err := ApplyOverlay(overlaySpecs, []Override{{Key: "it-enabled", Value: "banana"}})
if err == nil ||
!strings.Contains(err.Error(), `"it-enabled"`) ||
!strings.Contains(err.Error(), "clear it: member-console config clear it-enabled") {
t.Fatalf("want error naming key and remediation, got %v", err)
}
}
// A required key satisfied only by an override row still fails boot
// validation: ValidateStart runs before the database exists, so the
// bootstrap contract is environment-only. (cmd/start.go orders
// ValidateStart before ApplyOverlay; this pins that contract.)
func TestOverrideCannotSatisfyBootValidation(t *testing.T) {
validConfig()
bootEffective = map[string]Effective{}
groupSpecs := []ConfigKey{
{Name: "it-url", Type: TypeURL, RequiredGroup: "IT"},
{Name: "it-key", RequiredGroup: "IT"},
}
viper.Set("it-url", "https://it.example.com")
pending := []Override{{Key: "it-key", Value: "abc"}} // not yet applied — the point
if err := ValidateStart(groupSpecs); err == nil || !strings.Contains(err.Error(), "it-key") {
t.Fatalf("want partial-group failure naming it-key, got %v", err)
}
_ = pending
}