265 lines
7.5 KiB
Go
265 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")
|
||
|
|
}
|
||
|
|
}
|