205 lines
6 KiB
Go
205 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")
|
||
|
|
}
|
||
|
|
}
|