package streamgate import ( "context" "sort" "strings" "testing" "time" ) // stubEvaluator implements FilterEvaluator for tests. It exposes the id and // a return decision/error so test cases can drive readiness normalisation and // outcome composition without touching the guardrail. type stubEvaluator struct { id string decision FilterDecision err error evaluateInvoked bool } func (s *stubEvaluator) ID() string { return s.id } func (s *stubEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) { s.evaluateInvoked = true return s.decision, s.err } // newPassDecision returns a passing FilterDecision for use in readiness and // outcome composition tests. func newPassDecision(t *testing.T, filterID string) FilterDecision { t.Helper() ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) d, err := NewFilterDecision(FilterDecisionKindPass, "consumer.id", filterID, "rule.id", evidence, nil) if err != nil { t.Fatalf("newPassDecision: %v", err) } return d } // newViolationDecision returns a violating FilterDecision for enforcement // composition tests. func newViolationDecision(t *testing.T, filterID string) FilterDecision { t.Helper() ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) d, err := NewFilterDecision(FilterDecisionKindViolation, "consumer.id", filterID, "rule.id", evidence, nil) if err != nil { t.Fatalf("newViolationDecision: %v", err) } return d } // newFatalDecision returns a fatal FilterDecision for enforcement composition tests. func newFatalDecision(t *testing.T, filterID string) FilterDecision { t.Helper() ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) d, err := NewFilterDecision(FilterDecisionKindFatal, "consumer.id", filterID, "rule.id", evidence, nil) if err != nil { t.Fatalf("newFatalDecision: %v", err) } return d } // mustEvalError creates a FilterOutcome of kind evaluation_error with a // stable error code, failing the test on any error. func mustEvalError(t *testing.T, code string) FilterOutcome { t.Helper() o, err := NewFilterOutcomeEvaluationError(code) if err != nil { t.Fatalf("NewFilterOutcomeEvaluationError: %v", err) } return o } // --- [API-1] Resolved epoch filter binding --- // TestEpochFilterNormalizesApplicabilityAndRequirement verifies the readiness // normalisation table across all four states. Each matrix entry is driven by // a distinct stub evaluator so accidental coupling between tests is avoided. func TestEpochFilterNormalizesApplicabilityAndRequirement(t *testing.T) { passDecision := newPassDecision(t, "filter.id") cases := []struct { name string subscribed bool triggerReady bool blocksRelease bool enforcement FilterEnforcement wantForEpoch bool wantNotApplicable bool wantDeferred bool }{ { name: "subscribed_trigger_ready", subscribed: true, triggerReady: true, blocksRelease: false, enforcement: FilterEnforcementBlocking, wantForEpoch: true, wantNotApplicable: false, wantDeferred: false, }, { name: "subscribed_trigger_not_ready_blocks", subscribed: true, triggerReady: false, blocksRelease: true, enforcement: FilterEnforcementBlocking, wantForEpoch: false, wantNotApplicable: false, wantDeferred: true, }, { name: "subscribed_trigger_not_ready_nonblock", subscribed: true, triggerReady: false, blocksRelease: false, enforcement: FilterEnforcementObserveOnly, wantForEpoch: false, wantNotApplicable: true, wantDeferred: false, }, { name: "unsubscribed", subscribed: false, triggerReady: false, blocksRelease: false, enforcement: FilterEnforcementBlocking, wantForEpoch: false, wantNotApplicable: true, wantDeferred: false, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { e := &stubEvaluator{id: "filter.id", decision: passDecision} f, err := NewEpochFilter( e, tc.subscribed, tc.triggerReady, tc.blocksRelease, tc.enforcement, 100*time.Millisecond, ) if err != nil { t.Fatalf("NewEpochFilter: %v", err) } if f.EvaluatedForEpoch() != tc.wantForEpoch { t.Errorf("EvaluatedForEpoch() = %v, want %v", f.EvaluatedForEpoch(), tc.wantForEpoch) } oc := f.NormalizeOutcome() if tc.wantNotApplicable && oc.Kind() != FilterOutcomeKindNotApplicableForEpoch { t.Errorf("NormalizeOutcome() Kind() = %v, want not_applicable_for_epoch", oc.Kind()) } if tc.wantDeferred && oc.Kind() != FilterOutcomeKindDeferredByRequirement { t.Errorf("NormalizeOutcome() Kind() = %v, want deferred_by_requirement", oc.Kind()) } // Accessors must reflect construction inputs exactly. if f.SubscribedEventPresent() != tc.subscribed { t.Errorf("SubscribedEventPresent() = %v, want %v", f.SubscribedEventPresent(), tc.subscribed) } if f.TriggerReady() != tc.triggerReady { t.Errorf("TriggerReady() = %v, want %v", f.TriggerReady(), tc.triggerReady) } if f.BlocksRelease() != tc.blocksRelease { t.Errorf("BlocksRelease() = %v, want %v", f.BlocksRelease(), tc.blocksRelease) } if f.EnforcedAs() != tc.enforcement { t.Errorf("EnforcedAs() = %v, want %v", f.EnforcedAs(), tc.enforcement) } }) } } // TestEpochFilterRejectsInvalidExecutionContract verifies that NewEpochFilter // rejects every invalid constructor combination: nil evaluator, empty id, // invalid id grammar, unknown enforcement, and non-positive timeout. func TestEpochFilterRejectsInvalidExecutionContract(t *testing.T) { passDecision := newPassDecision(t, "filter.id") // --- nil evaluator --- t.Run("nil_evaluator", func(t *testing.T) { _, err := NewEpochFilter( nil, true, true, false, FilterEnforcementBlocking, 100*time.Millisecond, ) if err == nil { t.Error("NewEpochFilter(nil, ...) should return error") } }) // --- empty evaluator id --- t.Run("empty_evaluator_id", func(t *testing.T) { _, err := NewEpochFilter( &stubEvaluator{id: "", decision: passDecision}, true, true, false, FilterEnforcementBlocking, 100*time.Millisecond, ) if err == nil { t.Error("NewEpochFilter(empty id, ...) should return error") } }) // --- invalid evaluator id grammar --- t.Run("invalid_evaluator_id_grammar", func(t *testing.T) { _, err := NewEpochFilter( &stubEvaluator{id: "INVALID-ID!", decision: passDecision}, true, true, false, FilterEnforcementBlocking, 100*time.Millisecond, ) if err == nil { t.Error("NewEpochFilter(invalid id grammar, ...) should return error") } }) // --- unknown enforcement --- t.Run("unknown_enforcement", func(t *testing.T) { _, err := NewEpochFilter( &stubEvaluator{id: "filter.id", decision: passDecision}, true, true, false, "unknown", 100*time.Millisecond, ) if err == nil { t.Error("NewEpochFilter(unknown enforcement, ...) should return error") } }) // --- zero timeout --- t.Run("zero_timeout", func(t *testing.T) { _, err := NewEpochFilter( &stubEvaluator{id: "filter.id", decision: passDecision}, true, true, false, FilterEnforcementBlocking, 0, ) if err == nil { t.Error("NewEpochFilter(zero timeout, ...) should return error") } }) // --- negative timeout --- t.Run("negative_timeout", func(t *testing.T) { _, err := NewEpochFilter( &stubEvaluator{id: "filter.id", decision: passDecision}, true, true, false, FilterEnforcementBlocking, -1*time.Millisecond, ) if err == nil { t.Error("NewEpochFilter(negative timeout, ...) should return error") } }) } // TestEpochFilterTimeoutAndAccessorImmutability verifies that the timeout is // stored exactly and that accessor reads do not leak mutable state. func TestEpochFilterTimeoutAndAccessorImmutability(t *testing.T) { passDecision := newPassDecision(t, "filter.id") timeout := 42 * time.Millisecond f, err := NewEpochFilter( &stubEvaluator{id: "filter.id", decision: passDecision}, true, true, false, FilterEnforcementObserveOnly, timeout, ) if err != nil { t.Fatalf("NewEpochFilter: %v", err) } if f.EvaluationTimeout() != timeout { t.Errorf("EvaluationTimeout() = %v, want %v", f.EvaluationTimeout(), timeout) } // The evaluator is returned by reference; verify that it is the same // instance but that the accessors do not expose internal mutation paths. e := f.Evaluator() if e.ID() != "filter.id" { t.Errorf("Evaluator().ID() = %q, want %q", e.ID(), "filter.id") } } // --- [API-2] Immutable evaluation outcome set --- // TestEvaluationSetIsStableAndImmutable verifies that the set sorts by filter // id regardless of input order, rejects duplicates, and returns defensive // copies on every accessor path. func TestEvaluationSetIsStableAndImmutable(t *testing.T) { ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) // Build outcomes in reverse id order to exercise sort stability. d1, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "beta.id", "rule.id", evidence, nil) d2, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "alpha.id", "rule.id", evidence, nil) d3, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "gamma.id", "rule.id", evidence, nil) ocBeta, _ := NewFilterOutcomeEvaluated(d1) ocAlpha, _ := NewFilterOutcomeEvaluated(d2) ocGamma, _ := NewFilterOutcomeEvaluated(d3) betaO, _ := NewEpochFilterOutcome("beta.id", ocBeta, FilterEnforcementBlocking) alphaO, _ := NewEpochFilterOutcome("alpha.id", ocAlpha, FilterEnforcementBlocking) gammaO, _ := NewEpochFilterOutcome("gamma.id", ocGamma, FilterEnforcementBlocking) // Input in reverse id order: gamma, beta, alpha. inputs := []EpochFilterOutcome{ gammaO, betaO, alphaO, } set, err := NewEvaluationSet(inputs) if err != nil { t.Fatalf("NewEvaluationSet: %v", err) } // Stable order: alpha < beta < gamma. wantOrder := []string{"alpha.id", "beta.id", "gamma.id"} for i, w := range wantOrder { o, err := set.At(i) if err != nil { t.Fatalf("At(%d): %v", i, err) } if o.FilterID() != w { t.Errorf("At(%d).FilterID() = %q, want %q", i, o.FilterID(), w) } } // --- ByID lookup succeeds for sorted ids --- if _, err := set.ByID("alpha.id"); err != nil { t.Errorf("ByID('alpha.id'): %v", err) } if _, err := set.ByID("nonexistent"); err == nil { t.Error("ByID('nonexistent') should return error") } // --- All() returns defensive copy in stable order --- all := set.All() if len(all) != 3 { t.Fatalf("All() len = %d, want 3", len(all)) } if all[0].FilterID() != "alpha.id" { t.Errorf("All()[0].FilterID() = %q, want %q", all[0].FilterID(), "alpha.id") } // --- Mutating All() does not affect stored --- all[0] = EpochFilterOutcome{} if set.Len() != 3 { t.Errorf("Len() after All() mutation = %d, want 3", set.Len()) } // --- Duplicate filter id is rejected --- dupInputs := []EpochFilterOutcome{ alphaO, alphaO, } if _, err := NewEvaluationSet(dupInputs); err == nil { t.Error("NewEvaluationSet(duplicate id) should return error") } } // TestEpochFilterOutcomeValidatesFailurePolicy verifies that the failure // disposition is derived correctly from the outcome kind, enforcement mode, // and decision kind. It also verifies that invalid combinations are rejected. func TestEpochFilterOutcomeValidatesFailurePolicy(t *testing.T) { ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) _ = ts // reserved for future expansion cases := []struct { name string filterID string enforcement FilterEnforcement wantKind FilterOutcomeKind desiredDisp EvaluationFailureDisposition }{ { name: "pass_blocking", filterID: "f1", enforcement: FilterEnforcementBlocking, wantKind: FilterOutcomeKindEvaluated, desiredDisp: EvaluationFailureDispositionNone, }, { name: "pass_observe", filterID: "f2", enforcement: FilterEnforcementObserveOnly, wantKind: FilterOutcomeKindEvaluated, desiredDisp: EvaluationFailureDispositionNone, }, { name: "violation_blocking", filterID: "f3", enforcement: FilterEnforcementBlocking, wantKind: FilterOutcomeKindEvaluated, desiredDisp: EvaluationFailureDispositionBlockingFatal, }, { name: "violation_observe", filterID: "f4", enforcement: FilterEnforcementObserveOnly, wantKind: FilterOutcomeKindEvaluated, desiredDisp: EvaluationFailureDispositionObserveError, }, { name: "fatal_blocking", filterID: "f5", enforcement: FilterEnforcementBlocking, wantKind: FilterOutcomeKindEvaluated, desiredDisp: EvaluationFailureDispositionBlockingFatal, }, { name: "fatal_observe", filterID: "f6", enforcement: FilterEnforcementObserveOnly, wantKind: FilterOutcomeKindEvaluated, desiredDisp: EvaluationFailureDispositionObserveError, }, { name: "not_applicable_blocking", filterID: "f9", enforcement: FilterEnforcementBlocking, wantKind: FilterOutcomeKindNotApplicableForEpoch, desiredDisp: EvaluationFailureDispositionNone, }, { name: "deferred", filterID: "f10", enforcement: FilterEnforcementObserveOnly, wantKind: FilterOutcomeKindDeferredByRequirement, desiredDisp: EvaluationFailureDispositionNone, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { var oc FilterOutcome switch tc.name { case "violation_blocking", "violation_observe": oc = mustEvalOutcome(t, newViolationDecision(t, tc.filterID)) case "fatal_blocking", "fatal_observe": oc = mustEvalOutcome(t, newFatalDecision(t, tc.filterID)) case "not_applicable_blocking": oc = NewFilterOutcomeNotApplicableForEpoch() case "deferred": oc = NewFilterOutcomeDeferredByRequirement() default: oc = mustEvalOutcome(t, newPassDecision(t, tc.filterID)) } o, err := NewEpochFilterOutcome(tc.filterID, oc, tc.enforcement) if err != nil { t.Fatalf("NewEpochFilterOutcome: %v", err) } if o.FailureDisposition() != tc.desiredDisp { t.Errorf("FailureDisposition() = %v, want %v", o.FailureDisposition(), tc.desiredDisp) } if o.Outcome().Kind() != tc.wantKind { t.Errorf("Outcome().Kind() = %v, want %v", o.Outcome().Kind(), tc.wantKind) } if o.FilterID() != tc.filterID { t.Errorf("FilterID() = %q, want %q", o.FilterID(), tc.filterID) } if o.Enforcement() != tc.enforcement { t.Errorf("Enforcement() = %v, want %v", o.Enforcement(), tc.enforcement) } }) } // --- evaluation_error with blocking/observe_only --- t.Run("eval_error_blocking", func(t *testing.T) { eoc := mustEvalError(t, "err.code") o, err := NewEpochFilterOutcome("f7", eoc, FilterEnforcementBlocking) if err != nil { t.Fatalf("NewEpochFilterOutcome: %v", err) } if o.Outcome().Kind() != FilterOutcomeKindEvaluationError { t.Errorf("Outcome().Kind() = %v, want evaluation_error", o.Outcome().Kind()) } if o.FailureDisposition() != EvaluationFailureDispositionBlockingFatal { t.Errorf("FailureDisposition() = %v, want %v", o.FailureDisposition(), EvaluationFailureDispositionBlockingFatal) } }) t.Run("eval_error_observe", func(t *testing.T) { eoc := mustEvalError(t, "err.code") o, err := NewEpochFilterOutcome("f8", eoc, FilterEnforcementObserveOnly) if err != nil { t.Fatalf("NewEpochFilterOutcome: %v", err) } if o.Outcome().Kind() != FilterOutcomeKindEvaluationError { t.Errorf("Outcome().Kind() = %v, want evaluation_error", o.Outcome().Kind()) } if o.FailureDisposition() != EvaluationFailureDispositionObserveError { t.Errorf("FailureDisposition() = %v, want %v", o.FailureDisposition(), EvaluationFailureDispositionObserveError) } }) // --- Invalid combinations are rejected --- t.Run("invalid_empty_filter_id", func(t *testing.T) { _, err := NewEpochFilterOutcome("", NewFilterOutcomeNotApplicableForEpoch(), FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(empty id) should return error") } }) t.Run("invalid_bad_id_grammar", func(t *testing.T) { _, err := NewEpochFilterOutcome("BAD!ID", NewFilterOutcomeNotApplicableForEpoch(), FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(bad id grammar) should return error") } }) t.Run("invalid_unknown_enforcement", func(t *testing.T) { _, err := NewEpochFilterOutcome("f.id", NewFilterOutcomeNotApplicableForEpoch(), "unknown") if err == nil { t.Error("NewEpochFilterOutcome(unknown enforcement) should return error") } }) } // TestEvaluationSetRejectsInvalidEntries verifies that invalid EpochFilterOutcome // entries (empty filter id, bad grammar, invalid outcome) are rejected at // set construction. func TestEvaluationSetRejectsInvalidEntries(t *testing.T) { // Empty filter id is rejected at NewEpochFilterOutcome level. goodOutcome := NewFilterOutcomeNotApplicableForEpoch() badO, err := NewEpochFilterOutcome("", goodOutcome, FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(empty id) should return error") } if badO.FilterID() != "" { t.Errorf("badO.FilterID() = %q, want empty", badO.FilterID()) } } // TestEvaluationSetAccessorReturnsDefensiveCopy verifies that mutating the // return value of All() does not affect the set's stored outcomes. func TestEvaluationSetAccessorReturnsDefensiveCopy(t *testing.T) { ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) d, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "filter.id", "rule.id", evidence, nil) oc, _ := NewFilterOutcomeEvaluated(d) o, _ := NewEpochFilterOutcome("filter.id", oc, FilterEnforcementBlocking) set, err := NewEvaluationSet([]EpochFilterOutcome{o}) if err != nil { t.Fatalf("NewEvaluationSet: %v", err) } // --- All() mutation --- all := set.All() all[0] = EpochFilterOutcome{} if set.Len() != 1 { t.Error("All() replacement leaked to set length") } // --- At() element --- atO, err := set.At(0) if err != nil { t.Fatalf("At(0): %v", err) } if atO.FilterID() != "filter.id" { t.Errorf("At(0).FilterID() = %q, want %q", atO.FilterID(), "filter.id") } // --- ByID() lookup --- byID, err := set.ByID("filter.id") if err != nil { t.Fatalf("ByID: %v", err) } if byID.FilterID() != "filter.id" { t.Errorf("ByID.FilterID() = %q, want %q", byID.FilterID(), "filter.id") } } // TestEvaluationSetStableOrderAcrossInputOrders verifies that regardless of // the order the inputs are provided, the resulting EvaluationSet always // orders outcomes by ascending stable-token id. This ensures Arbiter inputs // do not depend on completion order. func TestEvaluationSetStableOrderAcrossInputOrders(t *testing.T) { ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) makeOutcome := func(id string) EpochFilterOutcome { d, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", id, "rule.id", evidence, nil) oc, _ := NewFilterOutcomeEvaluated(d) o, _ := NewEpochFilterOutcome(id, oc, FilterEnforcementBlocking) return o } // Generate a non-sorted order of ids. ids := []string{"z.id", "a.id", "m.id", "c.id", "y.id", "b.id"} // Sort them to know the expected order. sort.Strings(ids) // Build inputs in random order. inputs := make([]EpochFilterOutcome, len(ids)) for i, id := range ids { inputs[i] = makeOutcome(id) } set, err := NewEvaluationSet(inputs) if err != nil { t.Fatalf("NewEvaluationSet: %v", err) } for i, wantID := range ids { o, err := set.At(i) if err != nil { t.Fatalf("At(%d): %v", i, err) } if o.FilterID() != wantID { t.Errorf("At(%d).FilterID() = %q, want %q", i, o.FilterID(), wantID) } } } // TestEpochFilterOutcomeNoRawErrorPreserved verifies that the outcome stored // in EpochFilterOutcome does not contain raw error messages; only the // sanitized stable error code is retained. func TestEpochFilterOutcomeNoRawErrorPreserved(t *testing.T) { // Build an evaluation_error outcome with a stable error code. oc, _ := NewFilterOutcomeEvaluationError("runtime.guardrail.timeout") o, err := NewEpochFilterOutcome("f.id", oc, FilterEnforcementBlocking) if err != nil { t.Fatalf("NewEpochFilterOutcome: %v", err) } if o.Outcome().ErrorCode() != "runtime.guardrail.timeout" { t.Errorf("ErrorCode() = %q, want %q", o.Outcome().ErrorCode(), "runtime.guardrail.timeout") } if o.Outcome().Decision() != nil { t.Error("Evaluation error outcome must not carry a decision") } } // TestEpochFilterNormalizeOutcomeForReadyFilter verifies that a ready filter // (subscribed + triggerReady) produces an empty FilterOutcome from // NormalizeOutcome. The coordinator is responsible for wrapping the actual // evaluation result. func TestEpochFilterNormalizeOutcomeForReadyFilter(t *testing.T) { passDecision := newPassDecision(t, "filter.id") f, err := NewEpochFilter( &stubEvaluator{id: "filter.id", decision: passDecision}, true, true, false, FilterEnforcementBlocking, 100*time.Millisecond, ) if err != nil { t.Fatalf("NewEpochFilter: %v", err) } oc := f.NormalizeOutcome() // Ready filter has no pre-evaluation outcome; the kind is the zero value. if oc.Kind() != "" { t.Errorf("NormalizeOutcome().Kind() = %q, want empty (ready filter has no pre-evaluation outcome)", oc.Kind()) } } // TestEvaluationSetEmptyIsValid verifies that an empty EvaluationSet is valid // and that its accessors behave safely. func TestEvaluationSetEmptyIsValid(t *testing.T) { set, err := NewEvaluationSet(nil) if err != nil { t.Fatalf("NewEvaluationSet(nil): %v", err) } if set.Len() != 0 { t.Errorf("Len() = %d, want 0", set.Len()) } if set.All() != nil { t.Error("All() of empty set should return nil") } if _, err := set.At(0); err == nil { t.Error("At(0) on empty set should return error") } if _, err := set.ByID("any"); err == nil { t.Error("ByID on empty set should return error") } } // TestEpochFilterNoPartialConstructionLeak verifies that the filter // contract is also invariant under concurrent access — the constructor does // not produce a filter that can be observed in a partially constructed state. // This is enforced by the immutable design of the returned value. func TestEpochFilterNoPartialConstructionLeak(t *testing.T) { // The constructor validates all fields before returning. Since all // fields are stored in a struct value (not pointers to shared state), // a partially constructed EpochFilter can never be observed. This test // simply verifies that a fully valid filter has stable accessors. passDecision := newPassDecision(t, "filter.id") f, err := NewEpochFilter( &stubEvaluator{id: "filter.id", decision: passDecision}, true, true, false, FilterEnforcementBlocking, 100*time.Millisecond, ) if err != nil { t.Fatalf("NewEpochFilter: %v", err) } // All accessors should return consistent values. if f.ID() != "filter.id" { t.Errorf("ID() = %q, want %q", f.ID(), "filter.id") } if f.EnforcedAs() != FilterEnforcementBlocking { t.Errorf("EnforcedAs() = %v, want %v", f.EnforcedAs(), FilterEnforcementBlocking) } if !f.SubscribedEventPresent() { t.Error("SubscribedEventPresent() should be true") } if !f.TriggerReady() { t.Error("TriggerReady() should be true") } if !f.EvaluatedForEpoch() { t.Error("EvaluatedForEpoch() should be true for subscribed+triggered filter") } } // TestEpochFilterOutcomeValidateRejection verifies that EpochFilterOutcome.Validate() // rejects entries with invalid filter ids, invalid outcomes, invalid // enforcement, and invalid dispositions. func TestEpochFilterOutcomeValidateRejection(t *testing.T) { // --- Empty filter id --- o := EpochFilterOutcome{ filterID: StableToken{}, outcome: NewFilterOutcomeNotApplicableForEpoch(), enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionNone, } if err := o.Validate(); err == nil { t.Error("Validate() with empty filter id should return error") } // --- Invalid filter id grammar --- badToken, _ := NewStableTokenRequired("filterID", "BAD-ID!") o2 := EpochFilterOutcome{ filterID: badToken, outcome: NewFilterOutcomeNotApplicableForEpoch(), enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionNone, } if err := o2.Validate(); err == nil { t.Error("Validate() with bad id grammar should return error") } // --- Invalid outcome --- o3 := EpochFilterOutcome{ filterID: mustToken("filter.id"), outcome: FilterOutcome{}, enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionNone, } if err := o3.Validate(); err == nil { t.Error("Validate() with invalid outcome should return error") } // --- Invalid enforcement --- o4 := EpochFilterOutcome{ filterID: mustToken("filter.id"), outcome: NewFilterOutcomeNotApplicableForEpoch(), enforcement: "unknown", failureDisposition: EvaluationFailureDispositionNone, } if err := o4.Validate(); err == nil { t.Error("Validate() with invalid enforcement should return error") } } // TestEpochFilterOutcomeStableTokenGrammar verifies that EpochFilterOutcome // constructor enforces the stable-token grammar on the filter id. func TestEpochFilterOutcomeStableTokenGrammar(t *testing.T) { goodOutcome := NewFilterOutcomeNotApplicableForEpoch() // --- Uppercase rejected --- _, err := NewEpochFilterOutcome("Filter.ID", goodOutcome, FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(uppercase id) should return error") } // --- Special chars rejected --- _, err = NewEpochFilterOutcome("filter@id", goodOutcome, FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(special chars id) should return error") } // --- Empty rejected --- _, err = NewEpochFilterOutcome("", goodOutcome, FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(empty id) should return error") } // --- Valid grammar accepted --- _, err = NewEpochFilterOutcome("filter.id-01", goodOutcome, FilterEnforcementBlocking) if err != nil { t.Errorf("NewEpochFilterOutcome(valid id) = %v, want nil", err) } } // TestEpochFilterSnapshotsEvaluatorID verifies that EpochFilter.ID() returns // the evaluator id snapshot taken at construction time, not the evaluator's // current id after mutation. func TestEpochFilterSnapshotsEvaluatorID(t *testing.T) { passDecision := newPassDecision(t, "filter.id") mutable := &mutableIDEvaluator{id: "filter.id", decision: passDecision} f, err := NewEpochFilter( mutable, true, true, false, FilterEnforcementBlocking, 100*time.Millisecond, ) if err != nil { t.Fatalf("NewEpochFilter: %v", err) } // Snapshot must match the id at construction time. if got := f.ID(); got != "filter.id" { t.Errorf("ID() = %q, want %q", got, "filter.id") } // Mutate the evaluator id. mutable.id = "mutated.id" // ID() must still return the original snapshot. if got := f.ID(); got != "filter.id" { t.Errorf("ID() after mutation = %q, want %q", got, "filter.id") } // Evaluator().ID() must reflect the mutation (seam is preserved). if got := f.Evaluator().ID(); got != "mutated.id" { t.Errorf("Evaluator().ID() = %q, want %q", got, "mutated.id") } } // mutableIDEvaluator is a FilterEvaluator whose id can be changed at runtime // to test that EpochFilter snapshot is not affected by evaluator mutation. type mutableIDEvaluator struct { id string decision FilterDecision } func (m *mutableIDEvaluator) ID() string { return m.id } func (m *mutableIDEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) { return m.decision, nil } // TestEpochFilterOutcomeRejectsDecisionIDMismatch verifies that NewEpochFilterOutcome // rejects an evaluated outcome whose decision carries a different filter id // than the constructor's wrapper filter id. func TestEpochFilterOutcomeRejectsDecisionIDMismatch(t *testing.T) { ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) // Build a decision with a different filter id from the wrapper. d, err := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "other.id", "rule.id", evidence, nil) if err != nil { t.Fatalf("NewFilterDecision: %v", err) } _, err = NewEpochFilterOutcome("wrapper.id", mustEvalOutcome(t, d), FilterEnforcementBlocking) if err == nil { t.Error("NewEpochFilterOutcome(mismatched decision) should return error") } else if !strings.Contains(err.Error(), "filter id mismatch") { t.Errorf("error = %v, want to contain 'filter id mismatch'", err) } } // TestEvaluationSetRejectsForgedFailureDispositionMismatch verifies that a package-local // forged EpochFilterOutcome (constructed directly without NewEpochFilterOutcome) // whose failure disposition does not match the outcome kind and enforcement is // rejected by both Validate() and NewEvaluationSet. Covers representative // cases from the evaluated, evaluation-error, not-applicable, and deferred families. func TestEvaluationSetRejectsForgedFailureDispositionMismatch(t *testing.T) { ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) evidence := buildValidEvidence(t, ts) forgedToken, err := NewStableTokenRequired("filterID", "forged.id") if err != nil { t.Fatalf("NewStableTokenRequired: %v", err) } // Case 1: evaluated pass + blocking → should be none, but forged as blocking_fatal. t.Run("evaluated_pass_with_blocking_fatal", func(t *testing.T) { d, _ := NewFilterDecision(FilterDecisionKindPass, "consumer.id", "forged.id", "rule.id", evidence, nil) oc, _ := NewFilterOutcomeEvaluated(d) forged := EpochFilterOutcome{ filterID: forgedToken, outcome: oc, enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionBlockingFatal, } if err := forged.Validate(); err == nil { t.Error("Validate() should reject forged evaluated+pass with blocking_fatal disposition") } else if !strings.Contains(err.Error(), "failure disposition mismatch") { t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err) } if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil { t.Error("NewEvaluationSet should reject forged evaluated+pass with blocking_fatal") } }) // Case 2: evaluated violation + observe_only → should be observe_error, but forged as blocking_fatal. t.Run("evaluated_violation_with_observe_blocking_fatal", func(t *testing.T) { d, _ := NewFilterDecision(FilterDecisionKindViolation, "consumer.id", "forged.id", "rule.id", evidence, nil) oc, _ := NewFilterOutcomeEvaluated(d) forged := EpochFilterOutcome{ filterID: forgedToken, outcome: oc, enforcement: FilterEnforcementObserveOnly, failureDisposition: EvaluationFailureDispositionBlockingFatal, } if err := forged.Validate(); err == nil { t.Error("Validate() should reject forged evaluated+violation with blocking_fatal disposition under observe_only") } else if !strings.Contains(err.Error(), "failure disposition mismatch") { t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err) } if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil { t.Error("NewEvaluationSet should reject forged evaluated+violation with blocking_fatal under observe_only") } }) // Case 3: evaluation_error + blocking → should be blocking_fatal, but forged as observe_error. t.Run("eval_error_with_observe_error", func(t *testing.T) { eoc := mustEvalError(t, "err.code") forged := EpochFilterOutcome{ filterID: forgedToken, outcome: eoc, enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionObserveError, } if err := forged.Validate(); err == nil { t.Error("Validate() should reject forged eval_error with observe_error under blocking") } else if !strings.Contains(err.Error(), "failure disposition mismatch") { t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err) } if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil { t.Error("NewEvaluationSet should reject forged eval_error with observe_error under blocking") } }) // Case 4: not_applicable → should be none, but forged as blocking_fatal. t.Run("not_applicable_with_blocking_fatal", func(t *testing.T) { oc := NewFilterOutcomeNotApplicableForEpoch() forged := EpochFilterOutcome{ filterID: forgedToken, outcome: oc, enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionBlockingFatal, } if err := forged.Validate(); err == nil { t.Error("Validate() should reject forged not_applicable with blocking_fatal disposition") } else if !strings.Contains(err.Error(), "failure disposition mismatch") { t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err) } if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil { t.Error("NewEvaluationSet should reject forged not_applicable with blocking_fatal") } }) // Case 5: deferred → should be none, but forged as observe_error. t.Run("deferred_with_observe_error", func(t *testing.T) { oc := NewFilterOutcomeDeferredByRequirement() forged := EpochFilterOutcome{ filterID: forgedToken, outcome: oc, enforcement: FilterEnforcementObserveOnly, failureDisposition: EvaluationFailureDispositionObserveError, } if err := forged.Validate(); err == nil { t.Error("Validate() should reject forged deferred with observe_error disposition") } else if !strings.Contains(err.Error(), "failure disposition mismatch") { t.Errorf("Validate error = %v, want to contain 'failure disposition mismatch'", err) } if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil { t.Error("NewEvaluationSet should reject forged deferred with observe_error") } }) } // TestEvaluationSetRejectsForgedDecisionIDMismatch verifies that a package-local // forged EpochFilterOutcome (constructed directly without NewEpochFilterOutcome) // whose wrapper filter id does not match the evaluated decision's filter id is // rejected by both Validate() and NewEvaluationSet(). This is the set-boundary // regression complement to TestEpochFilterOutcomeRejectsDecisionIDMismatch, // which validates the constructor boundary. func TestEvaluationSetRejectsForgedDecisionIDMismatch(t *testing.T) { forged := EpochFilterOutcome{ filterID: mustToken("wrapper.id"), outcome: mustEvalOutcome(t, newPassDecision(t, "other.id")), enforcement: FilterEnforcementBlocking, failureDisposition: EvaluationFailureDispositionNone, } if err := forged.Validate(); err == nil { t.Fatal("Validate() should reject forged decision id mismatch") } else if !strings.Contains(err.Error(), "filter id mismatch") { t.Errorf("Validate error = %v, want to contain 'filter id mismatch'", err) } if _, err := NewEvaluationSet([]EpochFilterOutcome{forged}); err == nil { t.Fatal("NewEvaluationSet should reject forged decision id mismatch") } } // mustEvalOutcome wraps a FilterDecision into a FilterOutcome of kind // evaluated, failing the test on any error. func mustEvalOutcome(t *testing.T, d FilterDecision) FilterOutcome { t.Helper() o, err := NewFilterOutcomeEvaluated(d) if err != nil { t.Fatalf("NewFilterOutcomeEvaluated: %v", err) } return o } // ensure unused imports are consumed. var _ = context.Background var _ = sort.Strings