Closes ADR-0023 — all 4 phases now shipped. Final field: api.v2_enabled.
Approach: always-register-with-middleware-gate.
- /api/v2/* routes are now registered UNCONDITIONALLY at startup
- new Server.v2EnabledGate middleware reads the live config on every
request and returns 404 + {"error":"not_found","message":"v2 API is
currently disabled"} when api.v2_enabled is false
- the existing Config.WatchAndApply hot-reload pipeline already keeps
the config struct fresh — no extra plumbing needed
- flag flip takes effect on the NEXT request (not in-flight ones)
- no router rebuild, no restart
Tested via 3 unit tests in pkg/server/v2_gate_test.go:
- blocked-when-disabled: 404 + correct error message + JSON content-type
- passes-when-enabled: NOT 404, gate message ABSENT (handler executed)
- hot-reload-mid-life: same Server, same router, config flipped between
two requests → 404 then 200, proves the gate reads live config
Race detector clean. Full BDD suite green. ADR-0023 status promoted to
"Implemented" (no more parenthetical phase tracking).
The original "deferred" rationale in ADR-0023 listed router refactor as
the cost. Turns out the cost was minimal: ~25 lines of middleware + 3
tests + an `if` block deletion.
85 lines
3.3 KiB
Go
85 lines
3.3 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"dance-lessons-coach/pkg/config"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// TestV2EnabledGate_BlocksWhenDisabled verifies the ADR-0023 Phase 4
|
|
// hot-reload security property: when api.v2_enabled is false, ANY request
|
|
// to /api/v2/* returns 404 with a JSON body, not a 200, not a panic.
|
|
func TestV2EnabledGate_BlocksWhenDisabled(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.API.V2Enabled = false // explicit, even though it is the zero value
|
|
s := NewServer(cfg, context.Background())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"world"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusNotFound, w.Code, "v2 disabled should 404")
|
|
assert.Contains(t, w.Body.String(), "v2 API is currently disabled",
|
|
"response should explain why")
|
|
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
|
}
|
|
|
|
// TestV2EnabledGate_PassesWhenEnabled verifies the gate lets requests
|
|
// through to the actual v2 handler when api.v2_enabled is true. We use
|
|
// a v2 endpoint that exists and responds with a 2xx so we can assert
|
|
// "got past the gate, hit the handler".
|
|
func TestV2EnabledGate_PassesWhenEnabled(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.API.V2Enabled = true
|
|
s := NewServer(cfg, context.Background())
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"world"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
s.router.ServeHTTP(w, req)
|
|
|
|
// 200 = v2 handler executed. Anything other than 404 with the gate's
|
|
// message proves the gate let the request through.
|
|
assert.NotEqual(t, http.StatusNotFound, w.Code, "v2 enabled should not return 404 from gate")
|
|
assert.NotContains(t, w.Body.String(), "v2 API is currently disabled",
|
|
"gate message must NOT appear when enabled")
|
|
}
|
|
|
|
// TestV2EnabledGate_HotReloadEffect simulates the ADR-0023 Phase 4
|
|
// scenario: the same Server (same router) sees opposite responses
|
|
// before and after a config flip — proving the gate reads the live
|
|
// config rather than a snapshot from setupRoutes.
|
|
func TestV2EnabledGate_HotReloadEffect(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.API.V2Enabled = false
|
|
s := NewServer(cfg, context.Background())
|
|
|
|
// Round 1: disabled
|
|
req1 := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"a"}`))
|
|
req1.Header.Set("Content-Type", "application/json")
|
|
w1 := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w1, req1)
|
|
assert.Equal(t, http.StatusNotFound, w1.Code, "round 1 (disabled) should 404")
|
|
|
|
// Flip the config. In production, Config.WatchAndApply does this on
|
|
// file change; here we set the field directly to simulate the result.
|
|
cfg.API.V2Enabled = true
|
|
|
|
// Round 2: enabled — same Server, same router, just the config flipped
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"b"}`))
|
|
req2.Header.Set("Content-Type", "application/json")
|
|
w2 := httptest.NewRecorder()
|
|
s.router.ServeHTTP(w2, req2)
|
|
assert.NotEqual(t, http.StatusNotFound, w2.Code, "round 2 (enabled) should NOT 404")
|
|
assert.NotContains(t, w2.Body.String(), "v2 API is currently disabled")
|
|
}
|