From d45489714d231ee241cb96b497d6fc3860a72ff1 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Tue, 4 Aug 2026 00:16:26 +0200 Subject: [PATCH] feat(api): GET/PUT /api/config serving app + env configuration Co-Authored-By: Claude Fable 5 --- backend/cmd/geniusrund/main.go | 4 ++ backend/internal/api/appconfig.go | 88 +++++++++++++++++++++++ backend/internal/api/appconfig_test.go | 99 ++++++++++++++++++++++++++ backend/internal/api/server.go | 9 +++ 4 files changed, 200 insertions(+) create mode 100644 backend/internal/api/appconfig.go create mode 100644 backend/internal/api/appconfig_test.go diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index b664e4a..37a4f7b 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -66,6 +66,10 @@ func main() { FrontendURL: cfg.FrontendURL, }) + for _, e := range cfg.DisplayEnv() { + server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value}) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/backend/internal/api/appconfig.go b/backend/internal/api/appconfig.go new file mode 100644 index 0000000..fa80a40 --- /dev/null +++ b/backend/internal/api/appconfig.go @@ -0,0 +1,88 @@ +package api + +import ( + "encoding/json" + "net/http" + + "geniusrun/backend/internal/config" +) + +// EnvVar is one read-only environment-configuration entry, already +// display-safe (masking happens in config.Config.DisplayEnv). +type EnvVar struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type appConfigEntry struct { + Key string `json:"key"` + Value string `json:"value"` + Default string `json:"default"` + Overridden bool `json:"overridden"` + Description string `json:"description"` +} + +// configView assembles the GET/PUT response: registry defaults overlaid +// with DB overrides, plus the env snapshot. +func (s *Server) configView(r *http.Request) (map[string]any, error) { + overrides, err := s.DB.ConfigValues(r.Context()) + if err != nil { + return nil, err + } + registry := config.AppRegistry() + app := make([]appConfigEntry, 0, len(registry)) + for _, k := range registry { + value, overridden := overrides[k.Key] + if !overridden { + value = k.Default + } + app = append(app, appConfigEntry{ + Key: k.Key, Value: value, Default: k.Default, + Overridden: overridden, Description: k.Description, + }) + } + envVars := s.EnvVars + if envVars == nil { + envVars = []EnvVar{} + } + return map[string]any{"application": app, "environment": envVars}, nil +} + +func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { + resp, err := s.configView(r) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +// handlePutConfig updates application configuration. Cold: saved values +// take effect on the next backend restart; the response is just the +// refreshed view, same shape as GET. +func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) { + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + // Validate every pair before writing anything -- all-or-nothing. + for key, value := range body { + if err := config.ValidateAppValue(key, value); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + for key, value := range body { + if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + resp, err := s.configView(r) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/backend/internal/api/appconfig_test.go b/backend/internal/api/appconfig_test.go new file mode 100644 index 0000000..416d4d8 --- /dev/null +++ b/backend/internal/api/appconfig_test.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "geniusrun/backend/internal/garmin" + "geniusrun/backend/internal/garmin/mock" + "geniusrun/backend/internal/store" + appsync "geniusrun/backend/internal/sync" + + authmock "geniusrun/backend/internal/auth/mock" +) + +type configTestResponse struct { + Application []struct { + Key string `json:"key"` + Value string `json:"value"` + Default string `json:"default"` + Overridden bool `json:"overridden"` + Description string `json:"description"` + } `json:"application"` + Environment []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"environment"` +} + +func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) { + s, _, _ := newTestServer(t) + s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}} + + rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp configTestResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp.Application) != 1 { + t.Fatalf("expected 1 app-config entry, got %d", len(resp.Application)) + } + e := resp.Application[0] + if e.Key != "session.duration" || e.Value != "720" || e.Default != "720" || e.Overridden || e.Description == "" { + t.Fatalf("unexpected default entry: %+v", e) + } + if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" { + t.Fatalf("env snapshot not passed through: %+v", resp.Environment) + } +} + +func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) { + s, _, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"}) + if rec.Code != http.StatusOK { + t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String()) + } + var resp configTestResponse + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Application[0].Value != "168" || !resp.Application[0].Overridden { + t.Fatalf("override not reflected: %+v", resp.Application[0]) + } + + if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest { + t.Fatalf("unknown key status = %d, want 400", rec.Code) + } + rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid value status = %d, want 400", rec.Code) + } + rec = doJSON(t, router, http.MethodGet, "/api/config", nil) + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Application[0].Value != "168" { + t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0]) + } +} + +// Same gate as every data route: an unprovisioned session gets 403, per +// the repo's adversarial-isolation testing convention. +func TestConfig_RequiresProvisionedUser(t *testing.T) { + db, err := store.Open(t.TempDir() + "/config_gate_test.db") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer db.Close() + m := &mock.Client{} + s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) + + if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden { + t.Fatalf("GET status = %d, want 403", rec.Code) + } + if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden { + t.Fatalf("PUT status = %d, want 403", rec.Code) + } +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index fa5c133..503173d 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -48,6 +48,12 @@ type Server struct { GarminBase garmin.Config SyncConfig appsync.Config + // EnvVars is the read-only, display-safe environment-configuration + // snapshot served by GET /api/config -- built once in main.go from + // config.Config.DisplayEnv() (secrets already masked there); handlers + // never call os.Getenv. + EnvVars []EnvVar + mu sync.Mutex userGarmin map[int64]garmin.Client userSync map[int64]*appsync.Service @@ -363,6 +369,9 @@ func (s *Server) Router() http.Handler { r.Post("/reclassify", s.handleReclassifyAll) + r.Get("/config", s.handleGetConfig) + r.Put("/config", s.handlePutConfig) + r.Get("/progression/{kindID}", s.handleProgression) }) })