Some checks failed
ci / build (push) Failing after 23s
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
215 lines
6.4 KiB
Go
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")
|
|
}
|
|
}
|