FLUID-WP-0005 T02-T04. Redaction is deterministic configuration rather than a heuristic: the evidence store is append-only, so anything it accepts cannot be taken back out. Consumer identities are pseudonymized with HMAC rather than a bare hash, since a plain digest of a short identifier is reversible by enumeration. Applied rules are recorded on each event, so later analysis knows what it cannot see instead of mistaking an absence of evidence for evidence of absence. Redacted query parameters keep their key: which parameters a consumer sent is itself interface evidence. The topology analyzer requires several independent consumers before calling a shape a pattern. One client looping is a client bug; several converging on the same workaround is interface pressure. It also distinguishes a recovered error from a hard failure, because those two deserve different hypotheses. 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
204 lines
6 KiB
Go
204 lines
6 KiB
Go
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")
|
|
}
|
|
}
|