- Archive evidence-gate-core tasks (09, 10+09, 11+09,10) to archive/2026/07 - Improve streamgate evidence_tail with contract validation and filter registry enhancements - Add consumer_contract_test.go for Go streamgate - Update dispatch.py and test_dispatch.py
1329 lines
46 KiB
Go
1329 lines
46 KiB
Go
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, 100,
|
|
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, 100,
|
|
true, true, false,
|
|
FilterEnforcementBlocking,
|
|
100*time.Millisecond,
|
|
)
|
|
if err == nil {
|
|
t.Error("NewEpochFilter(nil, ...) should return error")
|
|
}
|
|
})
|
|
|
|
// --- negative priority ---
|
|
t.Run("negative_priority", func(t *testing.T) {
|
|
_, err := NewEpochFilter(
|
|
&stubEvaluator{id: "filter.id", decision: passDecision}, -1,
|
|
true, true, false,
|
|
FilterEnforcementBlocking,
|
|
100*time.Millisecond,
|
|
)
|
|
if err == nil {
|
|
t.Error("NewEpochFilter(negative priority, ...) should return error")
|
|
}
|
|
})
|
|
|
|
// --- empty evaluator id ---
|
|
t.Run("empty_evaluator_id", func(t *testing.T) {
|
|
_, err := NewEpochFilter(
|
|
&stubEvaluator{id: "", decision: passDecision}, 100,
|
|
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}, 100,
|
|
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}, 100,
|
|
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}, 100,
|
|
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}, 100,
|
|
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}, 100,
|
|
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", 100, ocBeta, FilterEnforcementBlocking)
|
|
alphaO, _ := NewEpochFilterOutcome("alpha.id", 100, ocAlpha, FilterEnforcementBlocking)
|
|
gammaO, _ := NewEpochFilterOutcome("gamma.id", 100, 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, 100, 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", 100, 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", 100, 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("", 100, 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", 100, 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", 100, NewFilterOutcomeNotApplicableForEpoch(), "unknown")
|
|
if err == nil {
|
|
t.Error("NewEpochFilterOutcome(unknown enforcement) should return error")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid_negative_priority", func(t *testing.T) {
|
|
_, err := NewEpochFilterOutcome("f.id", -1, NewFilterOutcomeNotApplicableForEpoch(), FilterEnforcementBlocking)
|
|
if err == nil {
|
|
t.Error("NewEpochFilterOutcome(negative priority) 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("", 100, 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", 100, 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, 100, 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", 100, 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}, 100,
|
|
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}, 100,
|
|
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", 100, goodOutcome, FilterEnforcementBlocking)
|
|
if err == nil {
|
|
t.Error("NewEpochFilterOutcome(uppercase id) should return error")
|
|
}
|
|
|
|
// --- Special chars rejected ---
|
|
_, err = NewEpochFilterOutcome("filter@id", 100, goodOutcome, FilterEnforcementBlocking)
|
|
if err == nil {
|
|
t.Error("NewEpochFilterOutcome(special chars id) should return error")
|
|
}
|
|
|
|
// --- Empty rejected ---
|
|
_, err = NewEpochFilterOutcome("", 100, goodOutcome, FilterEnforcementBlocking)
|
|
if err == nil {
|
|
t.Error("NewEpochFilterOutcome(empty id) should return error")
|
|
}
|
|
|
|
// --- Valid grammar accepted ---
|
|
_, err = NewEpochFilterOutcome("filter.id-01", 100, goodOutcome, FilterEnforcementBlocking)
|
|
if err != nil {
|
|
t.Errorf("NewEpochFilterOutcome(valid id) = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
// TestEpochFilterPrioritySnapshotReadyAndNonReady verifies that EpochFilter
|
|
// preserves the priority value for both ready and non-ready paths, and that
|
|
// the priority is accessible via the Priority() accessor regardless of
|
|
// EvaluatedForEpoch() state.
|
|
func TestEpochFilterPrioritySnapshotReadyAndNonReady(t *testing.T) {
|
|
passDecision := newPassDecision(t, "filter.id")
|
|
|
|
// Ready path: subscribed + triggerReady.
|
|
fReady, err := NewEpochFilter(
|
|
&stubEvaluator{id: "ready.filter", decision: passDecision}, 42,
|
|
true, true, false,
|
|
FilterEnforcementBlocking,
|
|
100*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilter ready: %v", err)
|
|
}
|
|
if fReady.Priority() != 42 {
|
|
t.Errorf("Ready filter Priority() = %d, want 42", fReady.Priority())
|
|
}
|
|
if !fReady.EvaluatedForEpoch() {
|
|
t.Error("Ready filter should be evaluated for epoch")
|
|
}
|
|
|
|
// Non-ready path (deferred): subscribed + trigger not ready + blocks release.
|
|
fDeferred, err := NewEpochFilter(
|
|
&stubEvaluator{id: "defer.filter", decision: passDecision}, 77,
|
|
true, false, true,
|
|
FilterEnforcementBlocking,
|
|
100*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilter deferred: %v", err)
|
|
}
|
|
if fDeferred.Priority() != 77 {
|
|
t.Errorf("Deferred filter Priority() = %d, want 77", fDeferred.Priority())
|
|
}
|
|
if fDeferred.EvaluatedForEpoch() {
|
|
t.Error("Deferred filter should NOT be evaluated for epoch")
|
|
}
|
|
|
|
// Non-ready path (not_applicable): subscribed + trigger not ready + !blocksRelease.
|
|
fNotApplicable, err := NewEpochFilter(
|
|
&stubEvaluator{id: "na.filter", decision: passDecision}, 99,
|
|
true, false, false,
|
|
FilterEnforcementObserveOnly,
|
|
100*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilter not_applicable: %v", err)
|
|
}
|
|
if fNotApplicable.Priority() != 99 {
|
|
t.Errorf("Not-applicable filter Priority() = %d, want 99", fNotApplicable.Priority())
|
|
}
|
|
if fNotApplicable.EvaluatedForEpoch() {
|
|
t.Error("Not-applicable filter should NOT be evaluated for epoch")
|
|
}
|
|
|
|
// Unsubscribed path.
|
|
fUnsub, err := NewEpochFilter(
|
|
&stubEvaluator{id: "unsub.filter", decision: passDecision}, 5,
|
|
false, false, false,
|
|
FilterEnforcementBlocking,
|
|
100*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilter unsubscribed: %v", err)
|
|
}
|
|
if fUnsub.Priority() != 5 {
|
|
t.Errorf("Unsubscribed filter Priority() = %d, want 5", fUnsub.Priority())
|
|
}
|
|
if fUnsub.EvaluatedForEpoch() {
|
|
t.Error("Unsubscribed filter should NOT be evaluated for epoch")
|
|
}
|
|
}
|
|
|
|
// TestEpochFilterOutcomePrioritySnapshotReadyAndNonReady verifies that
|
|
// EpochFilterOutcome preserves the priority value for both evaluated and
|
|
// non-evaluated outcome kinds.
|
|
func TestEpochFilterOutcomePrioritySnapshotReadyAndNonReady(t *testing.T) {
|
|
passDecision := newPassDecision(t, "f.ready")
|
|
|
|
// Evaluated outcome with priority.
|
|
ocEval, _ := NewFilterOutcomeEvaluated(passDecision)
|
|
oEval, err := NewEpochFilterOutcome("f.ready", 150, ocEval, FilterEnforcementBlocking)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilterOutcome evaluated: %v", err)
|
|
}
|
|
if oEval.Priority() != 150 {
|
|
t.Errorf("Evaluated outcome Priority() = %d, want 150", oEval.Priority())
|
|
}
|
|
|
|
// Not-applicable outcome with priority.
|
|
ocNA := NewFilterOutcomeNotApplicableForEpoch()
|
|
oNA, err := NewEpochFilterOutcome("f.na", 200, ocNA, FilterEnforcementBlocking)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilterOutcome not_applicable: %v", err)
|
|
}
|
|
if oNA.Priority() != 200 {
|
|
t.Errorf("Not-applicable outcome Priority() = %d, want 200", oNA.Priority())
|
|
}
|
|
|
|
// Deferred outcome with priority.
|
|
ocDeferred := NewFilterOutcomeDeferredByRequirement()
|
|
oDeferred, err := NewEpochFilterOutcome("f.defer", 250, ocDeferred, FilterEnforcementBlocking)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilterOutcome deferred: %v", err)
|
|
}
|
|
if oDeferred.Priority() != 250 {
|
|
t.Errorf("Deferred outcome Priority() = %d, want 250", oDeferred.Priority())
|
|
}
|
|
|
|
// Evaluation error outcome with priority.
|
|
evalError, _ := NewFilterOutcomeEvaluationError("err.code")
|
|
oErr, err := NewEpochFilterOutcome("f.err", 300, evalError, FilterEnforcementBlocking)
|
|
if err != nil {
|
|
t.Fatalf("NewEpochFilterOutcome eval_error: %v", err)
|
|
}
|
|
if oErr.Priority() != 300 {
|
|
t.Errorf("Eval-error outcome Priority() = %d, want 300", oErr.Priority())
|
|
}
|
|
}
|
|
|
|
// TestResolvedFilterPriorityPreservedFromRegistry verifies that ResolvedFilter
|
|
// preserves the priority from the registration's effective policy for both
|
|
// ready and non-ready paths.
|
|
func TestResolvedFilterPriorityPreservedFromRegistry(t *testing.T) {
|
|
ts := time.Second
|
|
|
|
f := newMockFilter("f-priority")
|
|
reg, _ := NewFilterRegistration(f, "cap-test", true, FilterEnforcementBlocking, ts, 55)
|
|
_ = reg
|
|
|
|
snap, _ := NewFilterRegistrySnapshot("gen-1", []FilterRegistration{reg}, nil)
|
|
rc := reqCtx("gen-1", "attempt-1", "staging")
|
|
rsnap, _ := snap.BeginRequest(rc)
|
|
|
|
target, _ := NewAttemptTarget("mg", "model-a", "prov-x", "primary", []string{"cap-test"})
|
|
resolved, err := rsnap.ResolveAttempt(target)
|
|
if err != nil {
|
|
t.Fatalf("ResolveAttempt: %v", err)
|
|
}
|
|
if len(resolved) != 1 {
|
|
t.Fatalf("expected 1 resolved, got %d", len(resolved))
|
|
}
|
|
|
|
rf := resolved[0]
|
|
if rf.Priority() != 55 {
|
|
t.Errorf("ResolvedFilter.Priority() = %d, want 55", rf.Priority())
|
|
}
|
|
|
|
// BindEpoch with ready=true should preserve the registry priority.
|
|
appReady, _ := NewFilterApplicability(rf.FilterID(), true, true)
|
|
efReady, err := rf.BindEpoch(1, appReady)
|
|
if err != nil {
|
|
t.Fatalf("BindEpoch ready: %v", err)
|
|
}
|
|
if efReady.Priority() != 55 {
|
|
t.Errorf("BindEpoch ready Priority() = %d, want 55", efReady.Priority())
|
|
}
|
|
|
|
// BindEpoch with trigger not ready should also preserve.
|
|
appDeferred, _ := NewFilterApplicability(rf.FilterID(), true, false)
|
|
efDeferred, err := rf.BindEpoch(1, appDeferred)
|
|
if err != nil {
|
|
t.Fatalf("BindEpoch deferred: %v", err)
|
|
}
|
|
if efDeferred.Priority() != 55 {
|
|
t.Errorf("BindEpoch deferred Priority() = %d, want 55", efDeferred.Priority())
|
|
}
|
|
|
|
// BindEpoch with not-applicable.
|
|
appNotApp, _ := NewFilterApplicability(rf.FilterID(), true, false)
|
|
efNotApp, err := rf.BindEpoch(1, appNotApp)
|
|
if err != nil {
|
|
t.Fatalf("BindEpoch not_applicable: %v", err)
|
|
}
|
|
if efNotApp.Priority() != 55 {
|
|
t.Errorf("BindEpoch not_applicable Priority() = %d, want 55", efNotApp.Priority())
|
|
}
|
|
|
|
// BindEpoch with unsubscribed.
|
|
appUnsub, _ := NewFilterApplicability(rf.FilterID(), false, false)
|
|
efUnsub, err := rf.BindEpoch(1, appUnsub)
|
|
if err != nil {
|
|
t.Fatalf("BindEpoch unsubscribed: %v", err)
|
|
}
|
|
if efUnsub.Priority() != 55 {
|
|
t.Errorf("BindEpoch unsubscribed Priority() = %d, want 55", efUnsub.Priority())
|
|
}
|
|
}
|
|
|
|
// TestGateCoordinatorEvaluateReturnsArbiterResult verifies that GateCoordinator
|
|
// Evaluate returns the exact ArbitrationResult produced by the arbiter, and
|
|
// that Submit also returns the same result.
|
|
func TestGateCoordinatorEvaluateReturnsArbiterResult(t *testing.T) {
|
|
batch := makeTestBatch(t)
|
|
|
|
passDecision := makeTestPassDecision(t, "pass.filter")
|
|
passEval := &stubEvaluator{id: "pass.filter", decision: passDecision}
|
|
|
|
fPass := mustMakeEpochFilter(t, passEval, true, true, false, FilterEnforcementBlocking)
|
|
|
|
// Custom arbiter that captures the result.
|
|
var capturedResult ArbitrationResult
|
|
capturingArb := &capturingArbiterForProp{
|
|
capture: &capturedResult,
|
|
}
|
|
|
|
coord, cleanup := mustNewCoordinator(t, capturingArb, 0)
|
|
defer cleanup()
|
|
|
|
set, evalResult, err := coord.Evaluate(context.Background(), batch, []EpochFilter{fPass})
|
|
if err != nil {
|
|
t.Fatalf("Evaluate error: %v", err)
|
|
}
|
|
|
|
// Evaluate must return the same result the arbiter produced.
|
|
if evalResult.Action() != capturedResult.Action() {
|
|
t.Errorf("Evaluate result Action = %v, arbiter result Action = %v", evalResult.Action(), capturedResult.Action())
|
|
}
|
|
if evalResult.FilterID() != capturedResult.FilterID() {
|
|
t.Errorf("Evaluate result FilterID = %s, arbiter result FilterID = %s", evalResult.FilterID(), capturedResult.FilterID())
|
|
}
|
|
if evalResult.RuleID() != capturedResult.RuleID() {
|
|
t.Errorf("Evaluate result RuleID = %s, arbiter result RuleID = %s", evalResult.RuleID(), capturedResult.RuleID())
|
|
}
|
|
if evalResult.BaseDisposition() != capturedResult.BaseDisposition() {
|
|
t.Errorf("Evaluate result BaseDisposition = %v, arbiter result BaseDisposition = %v", evalResult.BaseDisposition(), capturedResult.BaseDisposition())
|
|
}
|
|
|
|
// The set must be non-empty and consistent.
|
|
if set.Len() == 0 {
|
|
t.Error("Evaluate returned empty set")
|
|
}
|
|
|
|
// Submit must also return the same result.
|
|
submitResult, err := coord.Submit(context.Background(), batch, []EpochFilter{fPass})
|
|
if err != nil {
|
|
t.Fatalf("Submit error: %v", err)
|
|
}
|
|
if submitResult.Action() != capturedResult.Action() {
|
|
t.Errorf("Submit result Action = %v, expected %v", submitResult.Action(), capturedResult.Action())
|
|
}
|
|
if submitResult.FilterID() != capturedResult.FilterID() {
|
|
t.Errorf("Submit result FilterID = %s, expected %s", submitResult.FilterID(), capturedResult.FilterID())
|
|
}
|
|
if submitResult.RuleID() != capturedResult.RuleID() {
|
|
t.Errorf("Submit result RuleID = %s, expected %s", submitResult.RuleID(), capturedResult.RuleID())
|
|
}
|
|
if submitResult.BaseDisposition() != capturedResult.BaseDisposition() {
|
|
t.Errorf("Submit result BaseDisposition = %v, expected %v", submitResult.BaseDisposition(), capturedResult.BaseDisposition())
|
|
}
|
|
}
|
|
|
|
// violationReturningEvaluator is a FilterEvaluator that always returns a
|
|
// specific FilterDecision.
|
|
type violationReturningEvaluator struct {
|
|
id string
|
|
decision FilterDecision
|
|
}
|
|
|
|
func (v *violationReturningEvaluator) ID() string { return v.id }
|
|
func (v *violationReturningEvaluator) Evaluate(_ context.Context, _ EvidenceBatch) (FilterDecision, error) {
|
|
return v.decision, nil
|
|
}
|
|
|
|
// capturingArbiterForProp is an EpochArbiter that captures the result for
|
|
// propagation verification tests.
|
|
type capturingArbiterForProp struct {
|
|
capture *ArbitrationResult
|
|
}
|
|
|
|
func (c *capturingArbiterForProp) Arbitrate(_ context.Context, batch EvidenceBatch, set EvaluationSet) (ArbitrationResult, error) {
|
|
baseDisp, _ := batch.BaseDisposition()
|
|
res, err := NewArbitrationResult(ArbitrationActionRelease, baseDisp, "", "", nil, nil)
|
|
if err != nil {
|
|
return ArbitrationResult{}, err
|
|
}
|
|
if c.capture != nil {
|
|
*c.capture = res
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// ensure unused imports are consumed.
|
|
var _ = context.Background
|
|
var _ = sort.Strings
|
|
|
|
// 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, 100,
|
|
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", 100, 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
|