fluid-core/internal/observation/redact_test.go

205 lines
6 KiB
Go
Raw Normal View History

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