iop/packages/go/streamgate/event_test.go
toki c90bb755a9 feat: streamline plan/code-review/finalize router, add stream gate SDDs, sync dev-test inventory, update roadmap milestones
- 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
2026-07-24 15:11:00 +09:00

397 lines
13 KiB
Go

package streamgate
import (
"testing"
"time"
)
// TestNormalizedEventConstructorsAndBaseDisposition verifies that every public
// NormalizedEvent constructor produces a value with the correct base disposition
// for its kind, passes Validate, and rejects invalid shapes such as empty
// channel, zero timestamp, invalid HTTP status, and empty required payloads.
func TestNormalizedEventConstructorsAndBaseDisposition(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
// Table of valid kind → expected disposition, using the public constructor.
tests := []struct {
name string
kind EventKind
expectedDisp BaseEventDisposition
build func() (NormalizedEvent, error)
}{
{
name: "response_start",
kind: EventKindResponseStart,
expectedDisp: BaseDispositionHold,
build: func() (NormalizedEvent, error) {
return NewResponseStartEvent("ch", 200, map[string]string{"x-custom": "val"}, ts)
},
},
{
name: "text_delta",
kind: EventKindTextDelta,
expectedDisp: BaseDispositionReleaseCandidate,
build: func() (NormalizedEvent, error) {
return NewTextDeltaEvent("ch", "hello", ts)
},
},
{
name: "reasoning_delta",
kind: EventKindReasoningDelta,
expectedDisp: BaseDispositionReleaseCandidate,
build: func() (NormalizedEvent, error) {
return NewReasoningDeltaEvent("ch", "thinking", ts)
},
},
{
name: "tool_call_fragment",
kind: EventKindToolCallFragment,
expectedDisp: BaseDispositionReleaseCandidate,
build: func() (NormalizedEvent, error) {
return NewToolCallFragmentEvent("ch", "call-1", "get_weather", `{"q":"hi"}`, ts)
},
},
{
name: "terminal",
kind: EventKindTerminal,
expectedDisp: BaseDispositionTerminalSuccessCandidate,
build: func() (NormalizedEvent, error) {
return NewTerminalEvent("ch", ts)
},
},
{
name: "provider_error",
kind: EventKindProviderError,
expectedDisp: BaseDispositionTerminalErrorCandidate,
build: func() (NormalizedEvent, error) {
desc, err := NewExternalDescriptor("error.type", "code", "message", "")
if err != nil {
t.Fatalf("NewExternalDescriptor: %v", err)
}
return NewProviderErrorEvent("ch", desc, FailureCauseChain{}, ts)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ev, err := tc.build()
if err != nil {
t.Fatalf("constructor %s returned error: %v", tc.name, err)
}
if ev.Kind() != tc.kind {
t.Errorf("Kind() = %q, want %q", ev.Kind(), tc.kind)
}
if ev.Disposition() != tc.expectedDisp {
t.Errorf("Disposition() = %q, want %q", ev.Disposition(), tc.expectedDisp)
}
if err := ev.Validate(); err != nil {
t.Errorf("Validate() returned error: %v", err)
}
})
}
// --- Invalid shapes: each constructor must reject bad input ---
invalidTests := []struct {
name string
err func() error
}{
{"response_start_empty_channel", func() error {
_, err := NewResponseStartEvent("", 200, nil, ts)
return err
}},
{"response_start_zero_timestamp", func() error {
_, err := NewResponseStartEvent("ch", 200, nil, time.Time{})
return err
}},
{"response_start_status_below_min", func() error {
_, err := NewResponseStartEvent("ch", 99, nil, ts)
return err
}},
{"response_start_status_above_max", func() error {
_, err := NewResponseStartEvent("ch", 600, nil, ts)
return err
}},
{"text_delta_empty_channel", func() error {
_, err := NewTextDeltaEvent("", "hello", ts)
return err
}},
{"text_delta_empty_payload", func() error {
_, err := NewTextDeltaEvent("ch", "", ts)
return err
}},
{"text_delta_zero_timestamp", func() error {
_, err := NewTextDeltaEvent("ch", "hello", time.Time{})
return err
}},
{"reasoning_delta_empty_payload", func() error {
_, err := NewReasoningDeltaEvent("ch", "", ts)
return err
}},
{"tool_call_fragment_empty_id", func() error {
_, err := NewToolCallFragmentEvent("ch", "", "name", "args", ts)
return err
}},
{"tool_call_fragment_empty_name", func() error {
_, err := NewToolCallFragmentEvent("ch", "id", "", "args", ts)
return err
}},
{"tool_call_fragment_zero_timestamp", func() error {
_, err := NewToolCallFragmentEvent("ch", "id", "name", "args", time.Time{})
return err
}},
{"terminal_empty_channel", func() error {
_, err := NewTerminalEvent("", ts)
return err
}},
{"terminal_zero_timestamp", func() error {
_, err := NewTerminalEvent("ch", time.Time{})
return err
}},
{"provider_error_empty_channel", func() error {
desc, err := NewExternalDescriptor("error.type", "code", "message", "")
if err != nil {
t.Fatalf("NewExternalDescriptor: %v", err)
}
_, err = NewProviderErrorEvent("", desc, FailureCauseChain{}, ts)
return err
}},
{"provider_error_zero_timestamp", func() error {
desc, err := NewExternalDescriptor("error.type", "code", "message", "")
if err != nil {
t.Fatalf("NewExternalDescriptor: %v", err)
}
_, err = NewProviderErrorEvent("ch", desc, FailureCauseChain{}, time.Time{})
return err
}},
}
for _, tc := range invalidTests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.err(); err == nil {
t.Fatalf("%s: expected error, got nil", tc.name)
}
})
}
// --- Unknown kind: BaseDispositionOf and EventKind.Validate reject it ---
t.Run("unknown_kind_rejected", func(t *testing.T) {
unknown := EventKind("unknown_kind")
if _, err := BaseDispositionOf(unknown); err == nil {
t.Fatal("BaseDispositionOf with unknown kind should return error")
}
if err := unknown.Validate(); err == nil {
t.Fatal("EventKind.Validate with unknown kind should return error")
}
})
}
// TestResponseStartRejectsUnsafeHeaders verifies that NewResponseStart rejects
// unsafe staged metadata (invalid HTTP status, empty channel, zero timestamp)
// and that the headers map is defensively copied so that caller mutation of
// the input or accessor return value cannot alter the stored snapshot.
func TestResponseStartRejectsUnsafeHeaders(t *testing.T) {
ts := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
// --- Invalid HTTP status codes are rejected ---
t.Run("status_below_min_rejected", func(t *testing.T) {
if _, err := NewResponseStart("ch", 99, nil, ts); err == nil {
t.Fatal("status 99 should be rejected")
}
})
t.Run("status_above_max_rejected", func(t *testing.T) {
if _, err := NewResponseStart("ch", 600, nil, ts); err == nil {
t.Fatal("status 600 should be rejected")
}
})
t.Run("status_100_accepted", func(t *testing.T) {
rs, err := NewResponseStart("ch", 100, nil, ts)
if err != nil {
t.Fatalf("status 100 should be accepted: %v", err)
}
if rs.Status() != 100 {
t.Errorf("Status() = %d, want 100", rs.Status())
}
})
t.Run("status_599_accepted", func(t *testing.T) {
rs, err := NewResponseStart("ch", 599, nil, ts)
if err != nil {
t.Fatalf("status 599 should be accepted: %v", err)
}
if rs.Status() != 599 {
t.Errorf("Status() = %d, want 599", rs.Status())
}
})
// --- Empty channel and zero timestamp are rejected ---
t.Run("empty_channel_rejected", func(t *testing.T) {
if _, err := NewResponseStart("", 200, nil, ts); err == nil {
t.Fatal("empty channel should be rejected")
}
})
t.Run("zero_timestamp_rejected", func(t *testing.T) {
if _, err := NewResponseStart("ch", 200, nil, time.Time{}); err == nil {
t.Fatal("zero timestamp should be rejected")
}
})
// --- Defensive copy: mutating the input map after construction does not
// affect the stored headers. ---
t.Run("input_mutation_does_not_affect_stored", func(t *testing.T) {
input := map[string]string{"x-custom": "val", "x-other": "orig"}
rs, err := NewResponseStart("ch", 200, input, ts)
if err != nil {
t.Fatalf("NewResponseStart: %v", err)
}
// Mutate the caller-owned input map.
input["x-custom"] = "mutated"
input["x-new"] = "injected"
delete(input, "x-other")
got := rs.Headers()
if got["x-custom"] != "val" {
t.Errorf("x-custom = %q, want %q (input mutation leaked)", got["x-custom"], "val")
}
if got["x-other"] != "orig" {
t.Errorf("x-other = %q, want %q (input deletion leaked)", got["x-other"], "orig")
}
if _, ok := got["x-new"]; ok {
t.Error("x-new should not be present (input injection leaked)")
}
})
// --- Defensive copy: mutating the accessor return value does not affect
// the stored headers. ---
t.Run("accessor_mutation_does_not_affect_stored", func(t *testing.T) {
rs, err := NewResponseStart("ch", 200, map[string]string{"x-custom": "val"}, ts)
if err != nil {
t.Fatalf("NewResponseStart: %v", err)
}
got := rs.Headers()
got["x-custom"] = "mutated"
got["x-injected"] = "yes"
// Second call should return the original values.
got2 := rs.Headers()
if got2["x-custom"] != "val" {
t.Errorf("x-custom = %q, want %q (accessor mutation leaked)", got2["x-custom"], "val")
}
if _, ok := got2["x-injected"]; ok {
t.Error("x-injected should not be present (accessor injection leaked)")
}
})
// --- Nil headers are accepted; accessor returns a non-nil empty map
// (the constructor allocates via make(...)). ---
t.Run("nil_headers_accepted", func(t *testing.T) {
rs, err := NewResponseStart("ch", 200, nil, ts)
if err != nil {
t.Fatalf("NewResponseStart with nil headers: %v", err)
}
got := rs.Headers()
if got == nil {
t.Fatal("Headers() = nil, want non-nil empty map")
}
if len(got) != 0 {
t.Errorf("Headers() length = %d, want 0", len(got))
}
})
// --- NewResponseStartEvent also defensively copies headers. ---
t.Run("event_defensive_copy", func(t *testing.T) {
input := map[string]string{"x-custom": "val"}
ev, err := NewResponseStartEvent("ch", 200, input, ts)
if err != nil {
t.Fatalf("NewResponseStartEvent: %v", err)
}
input["x-custom"] = "mutated"
rs, err := ev.AsResponseStart()
if err != nil {
t.Fatalf("AsResponseStart: %v", err)
}
if rs.Headers()["x-custom"] != "val" {
t.Errorf("x-custom = %q, want %q (event input mutation leaked)", rs.Headers()["x-custom"], "val")
}
})
// --- Forbidden hop-by-hop / transport metadata headers ---
forbiddenHeaders := []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Proxy-Connection",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade",
"Content-Length",
}
for _, h := range forbiddenHeaders {
t.Run("forbidden/"+h, func(t *testing.T) {
headers := map[string]string{h: "value"}
// NewResponseStart rejects
if _, err := NewResponseStart("ch", 200, headers, ts); err == nil {
t.Errorf("NewResponseStart should reject forbidden header %q", h)
}
// NewResponseStartEvent rejects
if _, err := NewResponseStartEvent("ch", 200, headers, ts); err == nil {
t.Errorf("NewResponseStartEvent should reject forbidden header %q", h)
}
})
}
// --- Mixed-case forbidden headers are rejected case-insensitively ---
t.Run("forbidden_mixed_case", func(t *testing.T) {
headers := map[string]string{"CONTENT-LENGTH": "value", "Transfer-Encoding": "value"}
if _, err := NewResponseStart("ch", 200, headers, ts); err == nil {
t.Error("NewResponseStart should reject mixed-case forbidden headers")
}
if _, err := NewResponseStartEvent("ch", 200, headers, ts); err == nil {
t.Error("NewResponseStartEvent should reject mixed-case forbidden headers")
}
})
// --- Normal extension/end-to-end headers are preserved ---
t.Run("normal_headers_preserved", func(t *testing.T) {
headers := map[string]string{"x-custom": "val", "etag": "abc", "cache-control": "no-cache"}
rs, err := NewResponseStart("ch", 200, headers, ts)
if err != nil {
t.Fatalf("NewResponseStart: %v", err)
}
got := rs.Headers()
for k, v := range headers {
if got[k] != v {
t.Errorf("header %q = %q, want %q", k, got[k], v)
}
}
})
// --- Direct Validate on ResponseStart rejects forbidden headers ---
t.Run("direct_validate_rejects_forbidden", func(t *testing.T) {
rs := ResponseStart{
channel: "ch",
status: 200,
headers: map[string]string{"Content-Length": "value"},
timestamp: ts,
}
if err := rs.Validate(); err == nil {
t.Error("ResponseStart.Validate should reject forbidden header")
}
})
// --- Direct Validate on response-start NormalizedEvent rejects forbidden headers ---
t.Run("event_validate_rejects_forbidden", func(t *testing.T) {
ev := NormalizedEvent{
kind: EventKindResponseStart,
channel: "ch",
timestamp: ts,
disposition: BaseDispositionHold,
status: 200,
headers: map[string]string{"Transfer-Encoding": "value"},
}
if err := ev.Validate(); err == nil {
t.Error("NormalizedEvent.Validate should reject forbidden header for response_start")
}
})
}