package observation import ( "errors" "strings" "testing" "time" "github.com/tegwick/fluid-core/internal/contract" ) func testPolicy() RedactionPolicy { return DefaultRedactionPolicy([]byte("a-stable-salt-of-sufficient-length")) } func TestPolicyRequiresASalt(t *testing.T) { if err := (RedactionPolicy{}).Validate(); !errors.Is(err, ErrNoSalt) { t.Errorf("an unsalted policy validated: %v", err) } if err := testPolicy().Validate(); err != nil { t.Errorf("a salted policy was rejected: %v", err) } } // TestPseudonymIsStableAndOpaque covers both halves of the requirement: the // same consumer must look the same over time, and the value must not give the // identity back. func TestPseudonymIsStableAndOpaque(t *testing.T) { p := testPolicy() first := p.Pseudonymize("bernd@example.com") if first == "" { t.Fatal("pseudonymizing a real identity produced nothing") } if strings.Contains(first, "bernd") || strings.Contains(first, "example.com") { t.Errorf("pseudonym leaks the identity: %q", first) } for i := 0; i < 20; i++ { if again := p.Pseudonymize("bernd@example.com"); again != first { t.Fatalf("pseudonym is unstable: %q then %q", first, again) } } if p.Pseudonymize("someone-else@example.com") == first { t.Error("two identities collided") } // A different salt must produce a different value, or the mapping would be // portable between deployments. other := DefaultRedactionPolicy([]byte("a-completely-different-salt-value")) if other.Pseudonymize("bernd@example.com") == first { t.Error("pseudonym does not depend on the salt") } if p.Pseudonymize("") != "" { t.Error("an empty identity should stay empty rather than become a pseudonym") } } func TestCleanRouteRedactsCredentialsButKeepsShape(t *testing.T) { p := testPolicy() got, redacted := p.CleanRoute("/v1/entries?token=hunter2&limit=10") if !redacted { t.Fatal("a route carrying a token was not flagged as redacted") } if strings.Contains(got, "hunter2") { t.Errorf("token survived redaction: %q", got) } // Which parameters were sent is interface evidence in itself. if !strings.Contains(got, "token=") { t.Errorf("the parameter name was dropped, losing the evidence: %q", got) } if !strings.Contains(got, "limit=10") { t.Errorf("a harmless parameter was removed: %q", got) } plain, redacted := p.CleanRoute("/v1/entries") if redacted || plain != "/v1/entries" { t.Errorf("a clean route was altered: %q", plain) } } func TestUnparseableQueryIsDroppedEntirely(t *testing.T) { p := testPolicy() // A query that cannot be inspected cannot be shown to be safe. got, redacted := p.CleanRoute("/v1/entries?%zz") if !redacted { t.Error("an unparseable query was not flagged") } if strings.Contains(got, "%zz") { t.Errorf("unparseable query survived: %q", got) } } func TestScrubRemovesSensitivePatterns(t *testing.T) { p := testPolicy() for _, tc := range []struct{ name, in, mustNotContain string }{ {"bearer token", "upstream rejected: Bearer eyJhbGciOiJIUzI1NiJ9.abc", "eyJhbGciOiJIUzI1NiJ9"}, {"email", "no account for bernd@example.com", "bernd@example.com"}, {"connection string", "dial postgres://user:hunter2@db.internal/prod", "hunter2"}, } { got, hit := p.Scrub(tc.in) if !hit { t.Errorf("%s: not flagged as redacted", tc.name) } if strings.Contains(got, tc.mustNotContain) { t.Errorf("%s: sensitive value survived: %q", tc.name, got) } } if got, hit := p.Scrub("timeout after 5000ms"); hit || got != "timeout after 5000ms" { t.Errorf("harmless text was altered: %q", got) } } // TestApplyRecordsWhatItDid: silent redaction would let an analyst mistake an // absence of evidence for evidence of absence. func TestApplyRecordsWhatItDid(t *testing.T) { p := testPolicy() ev := contract.FluidTelemetry{ ConsumerRef: "bernd@example.com", Request: &contract.FluidTelemetryRequest{Route: "/v1/entries?api_key=secret"}, Error: &contract.FluidTelemetryError{Class: contract.FluidTelemetryErrorClassBackendFailure, Detail: "dial postgres://u:p@db/x"}, } p.Apply(&ev) if ev.Redaction == nil || !ev.Redaction.Applied { t.Fatal("redaction was applied but not recorded") } for _, want := range []string{"pseudonymize-consumer", "clean-route", "scrub-error-detail"} { found := false for _, r := range ev.Redaction.Rules { if r == want { found = true } } if !found { t.Errorf("rule %q not recorded; recorded: %v", want, ev.Redaction.Rules) } } if strings.Contains(ev.ConsumerRef, "@") { t.Error("consumer identity survived") } } func TestApplyIsIdempotent(t *testing.T) { // Events may pass through the filter more than once on their way to the // store; pseudonymizing a pseudonym would break consumer continuity. p := testPolicy() ev := contract.FluidTelemetry{ConsumerRef: "consumer-1"} p.Apply(&ev) once := ev.ConsumerRef p.Apply(&ev) if ev.ConsumerRef != once { t.Errorf("re-applying redaction changed the pseudonym: %q then %q", once, ev.ConsumerRef) } } func TestApplyOnCleanEventRecordsNoRedaction(t *testing.T) { p := testPolicy() ev := contract.FluidTelemetry{Request: &contract.FluidTelemetryRequest{Route: "/v1/entries"}} p.Apply(&ev) if ev.Redaction == nil { t.Fatal("redaction status not recorded at all") } if ev.Redaction.Applied { t.Errorf("a clean event was marked redacted: %v", ev.Redaction.Rules) } } func TestRetentionAndCohortFloor(t *testing.T) { p := testPolicy() now := time.Date(2026, 9, 4, 0, 0, 0, 0, time.UTC) old := contract.FluidTelemetry{OccurredAt: now.AddDate(0, 0, -91)} if !p.Expired(old, now) { t.Error("an event past retention was not expired") } recent := contract.FluidTelemetry{OccurredAt: now.AddDate(0, 0, -1)} if p.Expired(recent, now) { t.Error("a recent event was expired") } unbounded := p unbounded.RetentionDays = 0 if unbounded.Expired(old, now) { t.Error("unbounded retention expired an event") } // A cohort of one is a named individual with extra steps. if !p.SuppressSmallCohort(1) { t.Error("a cohort of one was reportable") } if p.SuppressSmallCohort(50) { t.Error("a large cohort was suppressed") } }