fluid-core/internal/observation/ingest_test.go
tegwick 7e0de9e5b7
Some checks failed
ci / build (push) Failing after 3h11m37s
Add telemetry ingest, feedback collector, pressure API and insight CLI
Completes FLUID-WP-0005. Normalization and redaction live on one path,
shared by the in-process emitter and the ingest endpoint: two paths with
two normalizations would eventually disagree, and the disagreement would
surface as a pressure finding that is really a pipeline bug.

Telemetry kind is inferred from event shape rather than defaulting to
"request", since an error filed as a request understates the interface's
failure rate. A malformed event in a batch does not discard the rest.

Feedback is stored as evidence and creates no pressure and no hypothesis
on its own, per API Standards 15, with the consumer recorded as the
actor so their untrusted status stays visible in the audit trail. The
feedback endpoint is the only consumer-reachable part of the control
plane.

The observation endpoints are not served at all when no pseudonymization
salt is configured, rather than served with a generated one: a salt that
changed per run would make the same consumer look new every time and
every cohort count wrong.

Adds an end-to-end test driving real traffic through the gateway and
confirming it becomes a classified pressure record, with no raw consumer
identity reaching the store.

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 03:19:06 +02:00

264 lines
7.5 KiB
Go

package observation
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
"github.com/tegwick/fluid-core/internal/contract"
"github.com/tegwick/fluid-core/internal/evidence"
)
func newIngest(t *testing.T) (*Ingest, *evidence.SQLStore) {
t.Helper()
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
in, err := NewIngest(store, "hall-publishing", testPolicy())
if err != nil {
t.Fatal(err)
}
return in, store
}
func TestIngestRejectsUnsaltedPolicy(t *testing.T) {
store, err := evidence.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err := NewIngest(store, "x", RedactionPolicy{}); !errors.Is(err, ErrNoSalt) {
t.Errorf("an unsalted ingest was constructed: %v", err)
}
}
// TestRedactionHappensOnTheWayIn: the store is append-only, so anything written
// unredacted stays that way forever.
func TestRedactionHappensOnTheWayIn(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
err := in.Write(ctx, contract.FluidTelemetry{
ConsumerRef: "bernd@example.com",
Request: &contract.FluidTelemetryRequest{Route: "/v1/entries?token=hunter2", Method: "GET"},
})
if err != nil {
t.Fatal(err)
}
rows, err := store.Telemetry(ctx, evidence.TelemetryFilter{})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("stored %d rows, want 1", len(rows))
}
stored := rows[0]
if strings.Contains(stored.ConsumerRef, "@") {
t.Errorf("raw consumer identity reached the store: %q", stored.ConsumerRef)
}
if strings.Contains(stored.Request.Route, "hunter2") {
t.Errorf("credential reached the store: %q", stored.Request.Route)
}
if stored.Redaction == nil || !stored.Redaction.Applied {
t.Error("redaction was applied but not recorded on the stored event")
}
}
// TestKindIsInferredFromShape: an error event filed as a request would
// understate the interface's failure rate.
func TestKindIsInferredFromShape(t *testing.T) {
in, _ := newIngest(t)
got, err := in.Normalize(contract.FluidTelemetry{
Error: &contract.FluidTelemetryError{Class: contract.FluidTelemetryErrorClassTimeout},
})
if err != nil {
t.Fatal(err)
}
if got.Kind != contract.FluidTelemetryKindError {
t.Errorf("kind = %s, want error", got.Kind)
}
got, err = in.Normalize(contract.FluidTelemetry{
Adoption: &contract.FluidTelemetryAdoption{Event: contract.FluidTelemetryAdoptionEventFirstUse},
})
if err != nil {
t.Fatal(err)
}
if got.Kind != contract.FluidTelemetryKindAdoption {
t.Errorf("kind = %s, want adoption", got.Kind)
}
}
func TestNormalizeFillsDefaultsAndGuardsInterface(t *testing.T) {
in, _ := newIngest(t)
got, err := in.Normalize(contract.FluidTelemetry{})
if err != nil {
t.Fatal(err)
}
if got.ID == "" || got.OccurredAt.IsZero() || got.SchemaVersion == "" {
t.Errorf("defaults not filled: %+v", got)
}
if got.InterfaceID != "hall-publishing" {
t.Errorf("interface = %q", got.InterfaceID)
}
// Another interface's telemetry must not land in this evidence store.
if _, err := in.Normalize(contract.FluidTelemetry{InterfaceID: "some-other-api"}); !errors.Is(err, ErrWrongInterface) {
t.Errorf("foreign telemetry accepted: %v", err)
}
}
// TestBatchKeepsGoodEventsWhenOneIsBad: dropping a hundred good events because
// one was malformed loses more than it protects.
func TestBatchKeepsGoodEventsWhenOneIsBad(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
accepted, rejected := in.WriteBatch(ctx, []contract.FluidTelemetry{
{Request: &contract.FluidTelemetryRequest{Route: "/a"}},
{InterfaceID: "wrong-interface"},
{Request: &contract.FluidTelemetryRequest{Route: "/b"}},
})
if accepted != 2 {
t.Errorf("accepted = %d, want 2", accepted)
}
if len(rejected) != 1 {
t.Errorf("rejected = %d, want 1", len(rejected))
}
rows, _ := store.Telemetry(ctx, evidence.TelemetryFilter{})
if len(rows) != 2 {
t.Errorf("stored %d rows, want 2", len(rows))
}
}
func TestErrorDetailIsBoundedWhenRawPayloadIsOff(t *testing.T) {
in, _ := newIngest(t)
long := strings.Repeat("x", 2000)
got, err := in.Normalize(contract.FluidTelemetry{
Error: &contract.FluidTelemetryError{
Class: contract.FluidTelemetryErrorClassBackendFailure,
Detail: long,
},
})
if err != nil {
t.Fatal(err)
}
if len(got.Error.Detail) > 600 {
t.Errorf("error detail was not bounded: %d chars", len(got.Error.Detail))
}
}
func TestFeedbackIsStoredAsEvidence(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
cohort := contract.CohortID("coding-agents")
got, err := in.RecordFeedback(ctx, contract.FluidFeedback{
Cohort: &cohort,
Goal: "publish a hall entry without splitting it by hand",
MissingCapability: "long-form serialization",
Outcome: "capability unavailable, contact bernd@example.com",
})
if err != nil {
t.Fatal(err)
}
if got.ID == "" || !strings.HasPrefix(string(got.ID), "F-") {
t.Errorf("feedback id = %q, want an F- prefix", got.ID)
}
// Consumers write free text; it passes the same filter as everything else.
if strings.Contains(got.Outcome, "bernd@example.com") {
t.Errorf("an address survived in feedback: %q", got.Outcome)
}
if _, err := store.Record(ctx, contract.KindFeedback, string(got.ID)); err != nil {
t.Errorf("feedback was not persisted: %v", err)
}
events, err := store.Events(ctx, evidence.EventFilter{EntityID: string(got.ID)})
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatalf("feedback left %d events, want 1", len(events))
}
// A consumer is untrusted; recording them as the actor keeps that visible.
if events[0].Actor.Type != contract.ActorTypeConsumer {
t.Errorf("actor type = %s, want consumer", events[0].Actor.Type)
}
}
func TestFeedbackNeedsAGoal(t *testing.T) {
in, _ := newIngest(t)
// Without a goal there is nothing to interpret later.
if _, err := in.RecordFeedback(context.Background(), contract.FluidFeedback{
Outcome: "it did not work",
}); err == nil {
t.Error("feedback with no goal was accepted")
}
}
// TestFeedbackDoesNotCreatePressure is the section 15 boundary: feedback is
// evidence and must not itself authorize a change.
func TestFeedbackDoesNotCreatePressure(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
if _, err := in.RecordFeedback(ctx, contract.FluidFeedback{
Goal: "I need a bulk publish endpoint",
MissingCapability: "bulk publish",
}); err != nil {
t.Fatal(err)
}
records, err := store.Records(ctx, contract.KindPressure)
if err != nil {
t.Fatal(err)
}
if len(records) != 0 {
t.Errorf("feedback created %d pressure records on its own", len(records))
}
}
func TestIngestIsUsableAsARuntimeSink(t *testing.T) {
ctx := context.Background()
in, store := newIngest(t)
// The gateway's emitter writes through this interface; redaction must apply
// on that path too, not only to events arriving over the endpoint.
var sink interface {
Write(context.Context, contract.FluidTelemetry) error
} = in
if err := sink.Write(ctx, contract.FluidTelemetry{
ConsumerRef: "raw-identity",
OccurredAt: time.Now(),
Request: &contract.FluidTelemetryRequest{Route: "/v1/x"},
}); err != nil {
t.Fatal(err)
}
rows, _ := store.Telemetry(ctx, evidence.TelemetryFilter{})
if len(rows) != 1 {
t.Fatalf("stored %d rows", len(rows))
}
if rows[0].ConsumerRef == "raw-identity" {
t.Error("the emitter path bypassed redaction")
}
}