diff --git a/backend/core/contracts/config_public.go b/backend/core/contracts/config_public.go new file mode 100644 index 00000000..31cd92b2 --- /dev/null +++ b/backend/core/contracts/config_public.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package contracts + +import "context" + +// PublicConfigProvider supplies the payload for GET /api/v1/config/public +// when a downstream plugin replaces Wavelet's default {configs, app} JSON. +type PublicConfigProvider interface { + PublicConfig(ctx context.Context) (any, error) +} diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go index 97a3b35d..5d171943 100644 --- a/backend/plugins/domain/system/plugin.go +++ b/backend/plugins/domain/system/plugin.go @@ -59,6 +59,16 @@ func (p *Plugin) Apply(ctx *core.Context) error { // 2. Public config ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) { + if p, err := core.Inject[contracts.PublicConfigProvider](ctx); err == nil && p != nil { + data, err := p.PublicConfig(c.Request.Context()) + if err != nil { + logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err) + response.AbortInternal(c, "public config unavailable") + return + } + c.JSON(http.StatusOK, response.OK(data)) + return + } configs, err := listPublicSystemConfigs(c.Request.Context(), ctx) if err != nil { logger.ErrorF(c.Request.Context(), "[System] query public system configs failed: %v", err) diff --git a/backend/plugins/domain/system/public_config_test.go b/backend/plugins/domain/system/public_config_test.go new file mode 100644 index 00000000..47d8840f --- /dev/null +++ b/backend/plugins/domain/system/public_config_test.go @@ -0,0 +1,149 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Wavelet/core" + "Wavelet/core/contracts" + + "github.com/gin-gonic/gin" +) + +type stubPublic struct{ payload any } + +func (s stubPublic) PublicConfig(context.Context) (any, error) { return s.payload, nil } + +type errPublic struct{ err error } + +func (s errPublic) PublicConfig(context.Context) (any, error) { return nil, s.err } + +func TestPublicConfigUsesProviderWhenPresent(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + core.Provide[contracts.PublicConfigProvider](ctx, stubPublic{payload: map[string]string{"k": "v"}}) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + body := invokePublicConfig(t, publicConfigHandler(t, ctx)) + assertFlatKV(t, body) +} + +func TestPublicConfigUsesProviderRegisteredAfterApply(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + core.Provide[contracts.PublicConfigProvider](ctx, stubPublic{payload: map[string]string{"k": "v"}}) + body := invokePublicConfig(t, publicConfigHandler(t, ctx)) + assertFlatKV(t, body) +} + +func TestPublicConfigDefaultWithoutProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + raw := invokePublicConfig(t, publicConfigHandler(t, ctx)) + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + t.Fatal(err) + } + if _, ok := data["configs"]; !ok { + t.Fatalf("data = %s, want key configs", raw) + } + if _, ok := data["app"]; !ok { + t.Fatalf("data = %s, want key app", raw) + } +} + +func TestPublicConfigProviderErrorAbortsInternal(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + core.Provide[contracts.PublicConfigProvider](ctx, errPublic{err: errors.New("secret boom")}) + if err := New().Apply(ctx); err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil) + publicConfigHandler(t, ctx)(c) + + if !c.IsAborted() { + t.Fatal("want request aborted on provider error") + } + if len(c.Errors) == 0 { + t.Fatal("want gin error on provider failure") + } + if got := c.Errors.Last().Error(); got != "public config unavailable" { + t.Fatalf("error = %q, want generic message", got) + } + if strings.Contains(w.Body.String(), "secret boom") { + t.Fatalf("leaked provider error: %s", w.Body.String()) + } +} + +func publicConfigHandler(t *testing.T, ctx *core.Context) gin.HandlerFunc { + t.Helper() + for _, rd := range ctx.Router().Routes() { + if rd.Method != "GET" || rd.Path != "/api/v1/config/public" { + continue + } + if len(rd.Handlers) == 0 { + break + } + switch h := rd.Handlers[0].(type) { + case gin.HandlerFunc: + return h + case func(*gin.Context): + return h + default: + t.Fatalf("unexpected handler type %T", rd.Handlers[0]) + } + } + t.Fatal("public config route missing") + return nil +} + +func invokePublicConfig(t *testing.T, handler gin.HandlerFunc) json.RawMessage { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil) + handler(c) + var body struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.Data +} + +func assertFlatKV(t *testing.T, raw json.RawMessage) { + t.Helper() + var data map[string]any + if err := json.Unmarshal(raw, &data); err != nil { + t.Fatal(err) + } + if data["k"] != "v" { + t.Fatalf("data = %s, want flat map", raw) + } + if _, ok := data["configs"]; ok { + t.Fatalf("data = %s, want provider payload without default configs", raw) + } + if _, ok := data["app"]; ok { + t.Fatalf("data = %s, want provider payload without default app", raw) + } +}