- Refactor plan, code-review, finalize-task-routing, refine-local-plans, router skills - Add agent-workflow-loop-orchestration skill and plan agent configs - Update roadmap: knowledge-tool-optimization milestones, stream-evidence-gate-core SDD - Add stream-evidence-gate-core task, archive, and Go streamgate package - Update dev-test inventory (edge/node smoke), agent-contract, edge-local-dev-guide - Deprecate USER_REVIEW for output-validation-filters SDD
1020 lines
35 KiB
Go
1020 lines
35 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,
|
|
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
|