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") } }