diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go index 09207c30..3d8c7c37 100644 --- a/backend/core/extpoints/config_resolve.go +++ b/backend/core/extpoints/config_resolve.go @@ -180,6 +180,13 @@ func formatEntryValue(value any, secret bool) string { if value == nil { return "" } + rv := reflect.ValueOf(value) + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return "" + } + return fmt.Sprint(rv.Elem().Interface()) + } return fmt.Sprint(value) } diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go index 5315ec50..7108d1db 100644 --- a/backend/core/extpoints/config_test.go +++ b/backend/core/extpoints/config_test.go @@ -224,6 +224,54 @@ func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) { assert.Equal(t, "86400", entries["app.session_age"].Value) } +type optionalBoolConfig struct { + RedirectTrailingSlash *bool `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH"` +} + +func TestBindBoolPointerFromFileAndEnv(t *testing.T) { + t.Run("absent stays nil", func(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}})) + require.NoError(t, r.Resolve()) + + var got optionalBoolConfig + require.NoError(t, r.Bind("app", &got)) + assert.Nil(t, got.RedirectTrailingSlash) + assert.Equal(t, "", r.Origin("app.redirect_trailing_slash")) + }) + + t.Run("file false", func(t *testing.T) { + src := newFakeSource() + src.values["app.redirect_trailing_slash"] = false + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}})) + require.NoError(t, r.Resolve()) + + var got optionalBoolConfig + require.NoError(t, r.Bind("app", &got)) + require.NotNil(t, got.RedirectTrailingSlash) + assert.False(t, *got.RedirectTrailingSlash) + assert.Equal(t, extpoints.OriginFile, r.Origin("app.redirect_trailing_slash")) + assert.False(t, r.Bool("app.redirect_trailing_slash", true)) + }) + + t.Run("env false", func(t *testing.T) { + src := newFakeSource() + src.env["APP_REDIRECT_TRAILING_SLASH"] = "false" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &optionalBoolConfig{}})) + require.NoError(t, r.Resolve()) + + var got optionalBoolConfig + require.NoError(t, r.Bind("app", &got)) + require.NotNil(t, got.RedirectTrailingSlash) + assert.False(t, *got.RedirectTrailingSlash) + assert.Equal(t, extpoints.OriginEnv, r.Origin("app.redirect_trailing_slash")) + }) +} + func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) { r := extpoints.NewConfigRegistry(nil) require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) diff --git a/backend/core/extpoints/config_value.go b/backend/core/extpoints/config_value.go index 1660f94b..b1cb0b98 100644 --- a/backend/core/extpoints/config_value.go +++ b/backend/core/extpoints/config_value.go @@ -33,15 +33,38 @@ func convertValue(raw any, typ reflect.Type) (any, error) { return convertSlice(raw, typ) case reflect.Struct: return convertStruct(raw, typ) + case reflect.Ptr: + return convertPointer(raw, typ) default: return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ) } } +// convertPointer decodes into the element type and returns a non-nil pointer to it. +// Nested pointers are rejected so configuration tags stay one level deep. +func convertPointer(raw any, typ reflect.Type) (any, error) { + elemType := typ.Elem() + if elemType.Kind() == reflect.Ptr { + return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ) + } + elem, err := convertValue(raw, elemType) + if err != nil { + return nil, err + } + ptr := reflect.New(elemType) + ptr.Elem().Set(reflect.ValueOf(elem)) + return ptr.Interface(), nil +} + func convertBool(raw any) (any, error) { switch v := raw.(type) { case bool: return v, nil + case *bool: + if v == nil { + return nil, fmt.Errorf("%w: nil *bool is not a boolean", ErrConfigType) + } + return *v, nil case string: parsed, err := strconv.ParseBool(strings.TrimSpace(v)) if err != nil { diff --git a/backend/plugins/drivers/driver_http/engine_slash_test.go b/backend/plugins/drivers/driver_http/engine_slash_test.go index 83c7e111..e58f588d 100644 --- a/backend/plugins/drivers/driver_http/engine_slash_test.go +++ b/backend/plugins/drivers/driver_http/engine_slash_test.go @@ -3,7 +3,11 @@ package driver_http -import "testing" +import ( + "testing" + + "Wavelet/core/extpoints" +) func TestBuildEngineDefaultRedirectsTrailingSlash(t *testing.T) { eng, err := BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{}) @@ -25,4 +29,85 @@ func TestBuildEngineCanDisableRedirectTrailingSlash(t *testing.T) { } } +func TestBindAppConfigDefaultKeepsTrailingSlashRedirect(t *testing.T) { + cfg := bindAppConfig(t, map[string]any{}, map[string]string{}) + if cfg.RedirectTrailingSlash != nil { + t.Fatal("absent redirect_trailing_slash must leave *bool nil") + } + eng, err := BuildEngineWithConfig(cfg, httpRedisConfig{}) + if err != nil { + t.Fatal(err) + } + if !eng.RedirectTrailingSlash { + t.Fatal("default RedirectTrailingSlash must be true after Bind") + } +} + +func TestBindAppConfigCanDisableTrailingSlashRedirect(t *testing.T) { + cfg := bindAppConfig(t, map[string]any{"app.redirect_trailing_slash": false}, nil) + if cfg.RedirectTrailingSlash == nil || *cfg.RedirectTrailingSlash { + t.Fatal("yaml false must bind *false") + } + eng, err := BuildEngineWithConfig(cfg, httpRedisConfig{}) + if err != nil { + t.Fatal(err) + } + if eng.RedirectTrailingSlash { + t.Fatal("RedirectTrailingSlash must honor bound false") + } +} + +func TestBindAppConfigEnvCanDisableTrailingSlashRedirect(t *testing.T) { + cfg := bindAppConfig(t, nil, map[string]string{"APP_REDIRECT_TRAILING_SLASH": "false"}) + if cfg.RedirectTrailingSlash == nil || *cfg.RedirectTrailingSlash { + t.Fatal("env false must bind *false") + } + eng, err := BuildEngineWithConfig(cfg, httpRedisConfig{}) + if err != nil { + t.Fatal(err) + } + if eng.RedirectTrailingSlash { + t.Fatal("RedirectTrailingSlash must honor env-bound false") + } +} + +type slashConfigSource struct { + values map[string]any + env map[string]string +} + +func (s slashConfigSource) Lookup(path string) (any, bool) { + v, ok := s.values[path] + return v, ok +} + +func (s slashConfigSource) LookupEnv(name string) (string, bool) { + v, ok := s.env[name] + return v, ok +} + +func (s slashConfigSource) Describe() string { return "slash-test" } + +func bindAppConfig(t *testing.T, values map[string]any, env map[string]string) httpAppConfig { + t.Helper() + if values == nil { + values = map[string]any{} + } + if env == nil { + env = map[string]string{} + } + r := extpoints.NewConfigRegistry(slashConfigSource{values: values, env: env}) + if err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "app", Target: &httpAppConfig{}}); err != nil { + t.Fatal(err) + } + if err := r.Resolve(); err != nil { + t.Fatal(err) + } + var cfg httpAppConfig + if err := r.Bind("app", &cfg); err != nil { + t.Fatal(err) + } + return cfg +} + func boolPtr(v bool) *bool { return &v }