fluid-core/internal/runtime/gateway_test.go
tegwick 791e419973
Some checks failed
ci / build (push) Failing after 23s
Add connector, response policy, telemetry emitter and gateway
Completes the request path for FLUID-WP-0003 T01-T03 and T05-T07. The
gateway resolves a revision, routes to the adapter process, and records
what happened, without ever depending on the control plane to serve.

Three behaviours carry tests because the architecture rests on them:
Emit never blocks against a stalled sink (Blueprint 34.2), the gateway
keeps serving after control-plane loss (invariant 2), and backend
internals do not leak into error responses (5.7).

The connector retries only idempotent methods, so a slow adapter cannot
cause a hall-of-helix entry to be published twice, and breakers are
per-revision so a broken candidate does not take the stable revision
down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 02:09:49 +02:00

215 lines
6.4 KiB
Go

package runtime
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/tegwick/fluid-core/internal/contract"
)
// recordingSink keeps every event for assertions.
type recordingSink struct {
mu sync.Mutex
events []contract.FluidTelemetry
}
func (s *recordingSink) Write(_ context.Context, ev contract.FluidTelemetry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, ev)
return nil
}
func (s *recordingSink) all() []contract.FluidTelemetry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]contract.FluidTelemetry, len(s.events))
copy(out, s.events)
return out
}
// harness wires a gateway in front of a stub adapter.
type harness struct {
gateway *Gateway
sink *recordingSink
emitter *Emitter
reg *Registry
adapter *httptest.Server
}
func newHarness(t *testing.T, handler http.HandlerFunc) *harness {
t.Helper()
adapter := httptest.NewServer(handler)
t.Cleanup(adapter.Close)
reg := NewRegistry(testInterface)
d := descriptor("R-1", contract.RevisionStateStable)
d.Runtime.Upstream = adapter.URL
if err := reg.PutRevision(d); err != nil {
t.Fatal(err)
}
if err := reg.PutPolicy(policyWith("R-1", 1)); err != nil {
t.Fatal(err)
}
sink := &recordingSink{}
emitter := NewEmitter(sink, EmitterOptions{Buffer: 64, Workers: 1})
t.Cleanup(emitter.Close)
gw, err := NewGateway(GatewayOptions{
Interface: testInterface,
Registry: reg,
Resolver: NewResolver(reg, true),
Connector: NewConnector(),
Emitter: emitter,
Cohorts: StaticCohort("publishing-jobs"),
Response: ResponsePolicy{FeedbackPath: "/v1/feedback"},
})
if err != nil {
t.Fatal(err)
}
return &harness{gateway: gw, sink: sink, emitter: emitter, reg: reg, adapter: adapter}
}
func TestGatewayForwardsAndRecords(t *testing.T) {
h := newHarness(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-FLUID-Revision"); got != "R-1" {
t.Errorf("adapter saw revision %q, want R-1", got)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"published":true}`))
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/hall-entries", strings.NewReader(`{"id":"e-1"}`))
req.Header.Set("X-FLUID-Consumer", "hall-publisher")
h.gateway.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("X-FLUID-Revision") != "R-1" {
t.Error("response does not name the revision that served it")
}
if rec.Header().Get("X-FLUID-Correlation") == "" {
t.Error("response carries no correlation reference")
}
h.emitter.Close()
events := h.sink.all()
if len(events) != 1 {
t.Fatalf("emitted %d events, want 1", len(events))
}
ev := events[0]
if ev.Kind != contract.FluidTelemetryKindRequest {
t.Errorf("kind = %s", ev.Kind)
}
if ev.Resolution == nil || ev.Resolution.Reason != contract.FluidTelemetryResolutionReasonStableDefault {
t.Errorf("resolution reason not recorded: %+v", ev.Resolution)
}
if ev.Revision == nil || *ev.Revision != "R-1" {
t.Error("revision not recorded on the event")
}
if ev.Request == nil || ev.Request.Route != "/v1/hall-entries" {
t.Errorf("request detail missing: %+v", ev.Request)
}
}
func TestGatewaySurfacesBackendFailureWithoutLeaking(t *testing.T) {
h := newHarness(t, func(w http.ResponseWriter, r *http.Request) {
panic("adapter exploded: dsn=postgres://user:hunter2@db.internal/prod")
})
// The stub's panic is handled by httptest's server, which closes the
// connection; the connector sees a transport failure.
h.adapter.Config.ErrorLog = nil
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/hall-entries/e-1", nil)
h.gateway.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway && rec.Code != http.StatusGatewayTimeout {
t.Fatalf("status = %d, want a gateway error", rec.Code)
}
var body ErrorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("error body is not JSON: %v", err)
}
if strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "postgres://") {
t.Error("backend internals leaked into the error response")
}
if body.Correlation == "" {
t.Error("error response carries no correlation reference")
}
if body.Feedback != "/v1/feedback" {
t.Error("error response does not point at the feedback endpoint")
}
h.emitter.Close()
events := h.sink.all()
if len(events) != 1 || events[0].Kind != contract.FluidTelemetryKindError {
t.Fatalf("failure was not recorded as error telemetry: %+v", events)
}
}
// TestGatewayServesWithoutControlPlane is the Blueprint invariant 2 check:
// evolution can stop without stopping the API. Here the control plane is
// represented by the registry's ability to accept updates; the gateway must
// keep serving from what it already holds.
func TestGatewayServesWithoutControlPlane(t *testing.T) {
h := newHarness(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`ok`))
})
// Simulate control-plane loss: no further policies or descriptors arrive,
// and telemetry delivery is dead.
h.emitter.Close()
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
h.gateway.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/hall-entries", nil))
if rec.Code != http.StatusOK {
t.Fatalf("request %d failed with %d after control-plane loss", i, rec.Code)
}
}
}
func TestGatewayRejectsOversizedBody(t *testing.T) {
h := newHarness(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("adapter should not have been reached")
})
h.gateway.opts.MaxBodyBytes = 16
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/hall-entries", strings.NewReader(strings.Repeat("x", 64)))
h.gateway.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestGatewayPropagatesCorrelation(t *testing.T) {
h := newHarness(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-FLUID-Correlation"); got != "c-upstream" {
t.Errorf("adapter saw correlation %q, want it forwarded", got)
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/hall-entries", nil)
req.Header.Set("X-FLUID-Correlation", "c-upstream")
h.gateway.ServeHTTP(rec, req)
if rec.Header().Get("X-FLUID-Correlation") != "c-upstream" {
t.Error("inbound correlation was not reused")
}
}