//go:build unit package config import ( "reflect" "sort" "strings" "testing" "github.com/spf13/viper" ) // collectMapstructureKeys walks a config struct and returns every dotted key // viper would need in order to populate it. func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) { for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.PkgPath != "" { continue // unexported } tag := field.Tag.Get("mapstructure") name, _, _ := strings.Cut(tag, ",") if name == "-" { continue } if name == "" { name = strings.ToLower(field.Name) } key := name if prefix != "" { key = prefix + "." + name } ft := field.Type for ft.Kind() == reflect.Ptr { ft = ft.Elem() } if ft.Kind() == reflect.Struct { collectMapstructureKeys(ft, key, out) continue } if ft.Kind() == reflect.Map { // A map cannot be expressed in a single environment variable, so it // is out of scope here — such settings need a config file either way. continue } if ft.Kind() == reflect.Slice { elem := ft.Elem() for elem.Kind() == reflect.Ptr { elem = elem.Elem() } if elem.Kind() == reflect.Struct { // AutomaticEnv exposes one string value. Viper's string-to-slice // hook can populate scalar slices, but it cannot decode a string // into []struct. Registering a default would turn silent ignore // into a startup unmarshal error, so structured slices remain // config-file-only just like maps. continue } } out[strings.ToLower(key)] = ft.String() } } // TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage // bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions // SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can // override a key already in that union but never introduces one, and the // viper_bind_struct escape hatch is compiled out (we build with -tags embed). // // So a Config field with no registered default is unreachable by environment // variable whenever the deployment has no config.yaml containing it — the // operator sets the variable, the loader discards it, and the feature behaves // as if it were never configured. That is exactly how image_storage credentials // were lost, silently disabling async image tasks for env-driven deployments. // // When this fails, register a zero-valued default in setEnvReachableDefaults // for each reported scalar key. Maps and slices of structs are config-file-only. func TestConfigKeysAreEnvReachable(t *testing.T) { bound := map[string]string{} collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) viper.Reset() t.Cleanup(viper.Reset) setDefaults() registered := map[string]struct{}{} for _, key := range viper.AllKeys() { registered[key] = struct{}{} } var unreachable []string for key, kind := range bound { if _, ok := registered[key]; !ok { unreachable = append(unreachable, key+" ("+kind+")") } } sort.Strings(unreachable) if len(unreachable) > 0 { t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s", len(unreachable), strings.Join(unreachable, "\n ")) } }