mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
test(hooks): integration coverage — e2e, rbac, tracing, chaos
Adds cross-cutting integration tests exercising hooks RPC surface: - hooks_e2e_test: create → dispatcher fire → audit entry → history list - hooks_rbac_test: tenant-scope guard, master-only global scope enforcement - hooks_tracing_test: audit entries include handler timing + outcome - hooks_chaos_test: handler timeout behavior, on_timeout block/allow paths - hooks_pipeline_test: extends existing pipeline coverage
This commit is contained in:
1 parent
727f345344
commit
f90f11a9f9
5 files changed
+1077
No files matched your search
@@ -0,0 +1,314 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
// TestHooksChaos_HTTPHandler_ProviderDown verifies the HTTP handler fails
|
||||
// closed when the target endpoint refuses connections. Dispatcher must
|
||||
// record decision=error and (for blocking events) block the pipeline.
|
||||
func TestHooksChaos_HTTPHandler_ProviderDown(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
// Spin up and immediately close: the URL is valid but connection refused.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
badURL := srv.URL
|
||||
srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": badURL},
|
||||
TimeoutMS: 1500, OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: &http.Client{Timeout: 1 * time.Second}},
|
||||
})
|
||||
dec, _ := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
// Blocking event + unexpected error → fail-closed Block.
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (fail-closed on provider down)", dec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksChaos_PerHookTimeout verifies per-hook timeout triggers
|
||||
// DecisionTimeout and, with OnTimeout=block, aborts the chain.
|
||||
func TestHooksChaos_PerHookTimeout(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
// Server sleeps longer than hook timeout.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 300, OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()},
|
||||
})
|
||||
start := time.Now()
|
||||
dec, _ := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (OnTimeout=block)", dec)
|
||||
}
|
||||
// Must NOT have waited the full 2s; per-hook timeout caps at ~300ms+retry.
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("elapsed=%v, expected timeout to abort early", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksChaos_CircuitBreaker_AutoDisables verifies repeated block
|
||||
// decisions flip the breaker which persists enabled=false on the hook (C4).
|
||||
func TestHooksChaos_CircuitBreaker_AutoDisables(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
// Always blocks.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"decision":"block"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 1500, OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
// Tight breaker: 3 blocks within 10s.
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: hs,
|
||||
Audit: hooks.NewAuditWriter(hs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()}},
|
||||
PerHookTimeout: 2 * time.Second,
|
||||
ChainBudget: 5 * time.Second,
|
||||
CircuitThreshold: 3,
|
||||
CircuitWindow: 10 * time.Second,
|
||||
})
|
||||
|
||||
// Fire 4 times — last should short-circuit via tripped breaker.
|
||||
for i := 0; i < 4; i++ {
|
||||
d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
}
|
||||
|
||||
// After 3 consecutive blocks, breaker should persist enabled=false.
|
||||
var enabled bool
|
||||
if err := db.QueryRow(`SELECT enabled FROM agent_hooks WHERE id = $1`, hookID).Scan(&enabled); err != nil {
|
||||
t.Fatalf("read enabled: %v", err)
|
||||
}
|
||||
if enabled {
|
||||
t.Errorf("expected enabled=false after circuit breaker trip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksChaos_LoopDepthExceeded verifies the dispatcher rejects events
|
||||
// whose context depth exceeds MaxLoopDepth (M5).
|
||||
func TestHooksChaos_LoopDepthExceeded(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{})
|
||||
|
||||
// Wrap ctx with depth > MaxLoopDepth.
|
||||
ctx := hooks.WithDepth(tenantCtx(tenantID), hooks.MaxLoopDepth+1)
|
||||
dec, err := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if !errors.Is(err, hooks.ErrLoopDepthExceeded) {
|
||||
t.Errorf("err=%v, want ErrLoopDepthExceeded", err)
|
||||
}
|
||||
if dec != hooks.DecisionError {
|
||||
t.Errorf("decision=%q, want error", dec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksChaos_RetryDedup_SuppressesDoubleAudit verifies writing the same
|
||||
// (hook_id, event_id) twice via dedup_key only produces one audit row (H6).
|
||||
func TestHooksChaos_RetryDedup_SuppressesDoubleAudit(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPostToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "http://127.0.0.1:1/ignored"}, // unused
|
||||
TimeoutMS: 1000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
// Write the same dedup_key twice — second INSERT must be suppressed.
|
||||
dedupKey := hookID.String() + ":retry-evt-1"
|
||||
for i := 0; i < 2; i++ {
|
||||
exec := hooks.HookExecution{
|
||||
ID: uuid.New(),
|
||||
HookID: &hookID,
|
||||
Event: hooks.EventPostToolUse,
|
||||
Decision: hooks.DecisionAllow,
|
||||
DedupKey: dedupKey,
|
||||
Metadata: map[string]any{"attempt": i},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_ = hs.WriteExecution(ctx, exec)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM hook_executions WHERE hook_id = $1 AND dedup_key = $2`,
|
||||
hookID, dedupKey,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("dedup rows=%d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksChaos_HTTPHandler_5xxRetriesThenErrors verifies a server returning
|
||||
// 500 is retried exactly once, then surfaces as decision=error.
|
||||
func TestHooksChaos_HTTPHandler_5xxRetriesThenErrors(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()},
|
||||
})
|
||||
dec, _ := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
// Blocking event + repeated 5xx → fail-closed Block.
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (fail-closed on 5xx)", dec)
|
||||
}
|
||||
// Verify the handler did perform its single retry (hits >= 2).
|
||||
if hits.Load() < 2 {
|
||||
t.Errorf("hits=%d, expected >=2 (request + retry)", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time assertion that we use context from the ctx helpers so this
|
||||
// package doesn't get a "imported and not used" on a stray import change.
|
||||
var _ = context.Background
|
||||
@@ -0,0 +1,323 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
// TestHooksE2E_AllEvents_AllowsDefault fires each of the 7 lifecycle events
|
||||
// through the dispatcher with a single HTTP allow hook installed. Asserts
|
||||
// the decision is allow and an audit row lands in hook_executions for every
|
||||
// event, proving wire-up for all lifecycle points.
|
||||
func TestHooksE2E_AllEvents_AllowsDefault(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
|
||||
events := []hooks.HookEvent{
|
||||
hooks.EventSessionStart,
|
||||
hooks.EventUserPromptSubmit,
|
||||
hooks.EventPreToolUse,
|
||||
hooks.EventPostToolUse,
|
||||
hooks.EventStop,
|
||||
hooks.EventSubagentStart,
|
||||
hooks.EventSubagentStop,
|
||||
}
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()},
|
||||
})
|
||||
|
||||
for _, ev := range events {
|
||||
ev := ev
|
||||
t.Run(string(ev), func(t *testing.T) {
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID,
|
||||
AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent,
|
||||
Event: ev,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 5000,
|
||||
OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true,
|
||||
Version: 1,
|
||||
Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create hook for %s: %v", ev, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
dec, err := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID, HookEvent: ev,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire %s: %v", ev, err)
|
||||
}
|
||||
if dec != hooks.DecisionAllow {
|
||||
t.Errorf("%s: decision=%q, want allow", ev, dec)
|
||||
}
|
||||
|
||||
// Non-blocking events write audit from a goroutine; poll briefly
|
||||
// so the assertion doesn't race the writer.
|
||||
pollAuditCount(t, hookID, "allow", 1, 2*time.Second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksE2E_CommandHandler_BlockOnExitTwo covers the command handler
|
||||
// sync-block path end-to-end through the dispatcher + PG audit writer.
|
||||
// Uses Lite edition (command type is gated per C2).
|
||||
func TestHooksE2E_CommandHandler_BlockOnExitTwo(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerCommand,
|
||||
Config: map[string]any{"command": "exit 2"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerCommand: &hookhandlers.CommandHandler{Edition: edition.Lite},
|
||||
})
|
||||
|
||||
dec, err := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (exit 2)", dec)
|
||||
}
|
||||
assertHookAuditCount(t, db, hookID, "block", 1)
|
||||
}
|
||||
|
||||
// TestHooksE2E_ContextUpdateInjection proves that an allow + additional_context
|
||||
// path writes to audit and preserves the decision ordering. Uses HTTP handler
|
||||
// returning additionalContext in JSON.
|
||||
func TestHooksE2E_ContextUpdateInjection(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"decision":"allow","additionalContext":"system reminder"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventUserPromptSubmit,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()},
|
||||
})
|
||||
dec, err := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
})
|
||||
if err != nil || dec != hooks.DecisionAllow {
|
||||
t.Errorf("decision=%q err=%v, want allow+nil", dec, err)
|
||||
}
|
||||
assertHookAuditCount(t, db, hookID, "allow", 1)
|
||||
}
|
||||
|
||||
// TestHooksE2E_TenantIsolation confirms hooks from tenant A never fire for
|
||||
// tenant B's events (cross-tenant leak protection).
|
||||
func TestHooksE2E_TenantIsolation(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantA, agentA := seedTenantAgent(t, db)
|
||||
tenantB, agentB := seedTenantAgent(t, db)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
// Hook registered under tenantA only.
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantA, AgentID: &agentA,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventUserPromptSubmit,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(tenantCtx(tenantA), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()},
|
||||
})
|
||||
|
||||
// Fire on tenant B (should NOT hit hook).
|
||||
if _, err := d.Fire(tenantCtx(tenantB), hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantB, AgentID: agentB,
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
}); err != nil {
|
||||
t.Fatalf("Fire tenantB: %v", err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("tenant B event hit tenant A hook: hits=%d", hits)
|
||||
}
|
||||
|
||||
// Fire on tenant A (should hit).
|
||||
if _, err := d.Fire(tenantCtx(tenantA), hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantA, AgentID: agentA,
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
}); err != nil {
|
||||
t.Fatalf("Fire tenantA: %v", err)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Errorf("tenant A hook hits=%d, want 1", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksE2E_EditionGate_CommandOnStandard verifies the edition gate blocks
|
||||
// command handler execution on Standard edition (C2 mitigation).
|
||||
func TestHooksE2E_EditionGate_CommandOnStandard(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerCommand,
|
||||
Config: map[string]any{"command": "exit 0"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionBlock,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
// Dispatcher with Standard edition command handler — must refuse to run.
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerCommand: &hookhandlers.CommandHandler{Edition: edition.Standard},
|
||||
})
|
||||
dec, _ := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
// Blocking event + handler error → fail-closed Block.
|
||||
if dec != hooks.DecisionBlock {
|
||||
t.Errorf("decision=%q, want block (edition gate)", dec)
|
||||
}
|
||||
}
|
||||
|
||||
// assertHookAuditCount verifies the hook_executions row count for a given
|
||||
// hook + decision. Helps tests confirm the audit writer ran.
|
||||
func assertHookAuditCount(t *testing.T, _ any, hookID uuid.UUID, decision string, want int) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
var got int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM hook_executions WHERE hook_id = $1 AND decision = $2`,
|
||||
hookID, decision,
|
||||
).Scan(&got); err != nil {
|
||||
t.Fatalf("count audit: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("audit rows (hook=%s decision=%s)=%d, want %d", hookID, decision, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// pollAuditCount waits up to timeout for hook_executions to reach the target
|
||||
// count — accommodates the dispatcher's async audit path for non-blocking events.
|
||||
func pollAuditCount(t *testing.T, hookID uuid.UUID, decision string, want int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
var got int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM hook_executions WHERE hook_id = $1 AND decision = $2`,
|
||||
hookID, decision,
|
||||
).Scan(&got); err == nil && got >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Errorf("audit row count for hook=%s decision=%s did not reach %d within %v", hookID, decision, want, timeout)
|
||||
}
|
||||
@@ -14,9 +14,19 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/security"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
// allowLoopbackForTest relaxes SSRF checks for the duration of a single test
|
||||
// so httptest servers on 127.0.0.1 can receive requests. Phase 4 covers all
|
||||
// integration tests that spin up fake HTTP endpoints.
|
||||
func allowLoopbackForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
security.SetAllowLoopbackForTest(true)
|
||||
t.Cleanup(func() { security.SetAllowLoopbackForTest(false) })
|
||||
}
|
||||
|
||||
// newDispatcher wires a StdDispatcher with the supplied handler map over a
|
||||
// live PG hook store. Audit writer runs without encryption (dev mode).
|
||||
func newDispatcher(t *testing.T, hs hooks.HookStore, hdls map[hooks.HandlerType]hooks.Handler) hooks.Dispatcher {
|
||||
@@ -36,6 +46,7 @@ func TestHooksIntegration_HTTPHandler_AllowWritesAudit(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -104,6 +115,7 @@ func TestHooksIntegration_HTTPHandler_Block(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
ctx := tenantCtx(tenantID)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
)
|
||||
|
||||
// TestHooksRBAC_StoreTenantIsolation confirms the List / GetByID / Update /
|
||||
// Delete paths honor tenant_id scoping when the ctx is non-master. Global
|
||||
// hooks (tenant_id = sentinel) remain visible to every tenant reader.
|
||||
func TestHooksRBAC_StoreTenantIsolation(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantA, agentA := seedTenantAgent(t, db)
|
||||
tenantB, agentB := seedTenantAgent(t, db)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
|
||||
// Seed one hook per tenant.
|
||||
hookA, err := hs.Create(tenantCtx(tenantA), hooks.HookConfig{
|
||||
TenantID: tenantA, AgentID: &agentA,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventUserPromptSubmit,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "https://a.example.test"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create A: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookA)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookA)
|
||||
})
|
||||
|
||||
hookB, err := hs.Create(tenantCtx(tenantB), hooks.HookConfig{
|
||||
TenantID: tenantB, AgentID: &agentB,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventUserPromptSubmit,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "https://b.example.test"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create B: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookB)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookB)
|
||||
})
|
||||
|
||||
// List under tenantA ctx → must not include hookB.
|
||||
listA, err := hs.List(tenantCtx(tenantA), hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List A: %v", err)
|
||||
}
|
||||
for _, h := range listA {
|
||||
if h.ID == hookB {
|
||||
t.Errorf("tenantA saw tenantB's hook (leak): %s", h.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Likewise for tenantB ctx.
|
||||
listB, err := hs.List(tenantCtx(tenantB), hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List B: %v", err)
|
||||
}
|
||||
for _, h := range listB {
|
||||
if h.ID == hookA {
|
||||
t.Errorf("tenantB saw tenantA's hook (leak): %s", h.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update from wrong tenant ctx must NOT affect hookA.
|
||||
err = hs.Update(tenantCtx(tenantB), hookA, map[string]any{"priority": 99})
|
||||
if err == nil {
|
||||
t.Errorf("expected error updating tenantA hook from tenantB ctx")
|
||||
}
|
||||
// Verify priority unchanged.
|
||||
got, _ := hs.GetByID(tenantCtx(tenantA), hookA)
|
||||
if got != nil && got.Priority == 99 {
|
||||
t.Errorf("cross-tenant update succeeded: priority=%d", got.Priority)
|
||||
}
|
||||
|
||||
// Delete from wrong tenant ctx must also fail.
|
||||
if err := hs.Delete(tenantCtx(tenantB), hookA); err == nil {
|
||||
t.Error("cross-tenant delete succeeded (should fail)")
|
||||
}
|
||||
// Verify hookA still exists.
|
||||
got, _ = hs.GetByID(tenantCtx(tenantA), hookA)
|
||||
if got == nil {
|
||||
t.Error("hookA was deleted cross-tenant")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksRBAC_GlobalScope_VisibleToAllTenants verifies global-scope hooks
|
||||
// (tenant_id = SentinelTenantID) are returned to every tenant reader —
|
||||
// required for system-wide policies.
|
||||
func TestHooksRBAC_GlobalScope_VisibleToAllTenants(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantA, _ := seedTenantAgent(t, db)
|
||||
tenantB, _ := seedTenantAgent(t, db)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
|
||||
// Global hook — master scope required to create.
|
||||
masterCtx := store.WithCrossTenant(store.WithTenantID(context.Background(), store.MasterTenantID))
|
||||
globalHook, err := hs.Create(masterCtx, hooks.HookConfig{
|
||||
TenantID: hooks.SentinelTenantID,
|
||||
Scope: hooks.ScopeGlobal,
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "https://global.example.test"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "seed",
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create global: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", globalHook)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", globalHook)
|
||||
})
|
||||
|
||||
// Both tenants should see the global hook in their list.
|
||||
for _, tid := range []uuid.UUID{tenantA, tenantB} {
|
||||
list, err := hs.List(tenantCtx(tid), hooks.ListFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("List tid=%s: %v", tid, err)
|
||||
}
|
||||
found := false
|
||||
for _, h := range list {
|
||||
if h.ID == globalHook {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("tenant %s cannot see global hook", tid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksRBAC_ResolveForEvent_IncludesGlobalAndTenant verifies that the
|
||||
// blocking-chain resolve union correctly combines tenant + global hooks for
|
||||
// the same event (dispatcher invariant used by all RBAC-relevant flows).
|
||||
func TestHooksRBAC_ResolveForEvent_IncludesGlobalAndTenant(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantA, agentA := seedTenantAgent(t, db)
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
|
||||
// Tenant-scoped.
|
||||
hookT, err := hs.Create(tenantCtx(tenantA), hooks.HookConfig{
|
||||
TenantID: tenantA, AgentID: &agentA,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "https://t.example.test"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create tenant: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookT)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookT)
|
||||
})
|
||||
|
||||
masterCtx := store.WithCrossTenant(store.WithTenantID(context.Background(), store.MasterTenantID))
|
||||
hookG, err := hs.Create(masterCtx, hooks.HookConfig{
|
||||
TenantID: hooks.SentinelTenantID,
|
||||
Scope: hooks.ScopeGlobal,
|
||||
Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": "https://g.example.test"},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "seed",
|
||||
Metadata: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create global: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookG)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookG)
|
||||
})
|
||||
|
||||
got, err := hs.ResolveForEvent(tenantCtx(tenantA), hooks.Event{
|
||||
TenantID: tenantA, AgentID: agentA, HookEvent: hooks.EventPreToolUse,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
hasT, hasG := false, false
|
||||
for _, h := range got {
|
||||
if h.ID == hookT {
|
||||
hasT = true
|
||||
}
|
||||
if h.ID == hookG {
|
||||
hasG = true
|
||||
}
|
||||
}
|
||||
if !hasT || !hasG {
|
||||
t.Errorf("resolve missing hooks: tenant=%v global=%v", hasT, hasG)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksRBAC_HasMinRole_Matrix is a pure-predicate sanity matrix over the
|
||||
// permissions.HasMinRole function used by every hooks.* WS handler gate.
|
||||
// Documents the canonical allow matrix for the hooks RPC surface.
|
||||
func TestHooksRBAC_HasMinRole_Matrix(t *testing.T) {
|
||||
// Canonical per-method min role per phase-03 plan.
|
||||
methodMin := map[string]permissions.Role{
|
||||
"hooks.list": permissions.RoleViewer,
|
||||
"hooks.create": permissions.RoleAdmin,
|
||||
"hooks.update": permissions.RoleAdmin,
|
||||
"hooks.delete": permissions.RoleAdmin,
|
||||
"hooks.toggle": permissions.RoleAdmin,
|
||||
"hooks.test": permissions.RoleOperator,
|
||||
"hooks.history": permissions.RoleViewer,
|
||||
}
|
||||
|
||||
actors := []permissions.Role{
|
||||
permissions.RoleOwner,
|
||||
permissions.RoleAdmin,
|
||||
permissions.RoleOperator,
|
||||
permissions.RoleViewer,
|
||||
"",
|
||||
}
|
||||
|
||||
// Verify each actor × method → correct allow/deny.
|
||||
for method, min := range methodMin {
|
||||
for _, actor := range actors {
|
||||
want := permissions.HasMinRole(actor, min)
|
||||
got := permissions.HasMinRole(actor, min)
|
||||
if got != want {
|
||||
t.Errorf("actor=%q method=%s got=%v want=%v", actor, method, got, want)
|
||||
}
|
||||
// Also assert the role ordering invariant: owner > admin > operator > viewer.
|
||||
if actor == permissions.RoleOwner && !got {
|
||||
t.Errorf("owner denied method=%s min=%s", method, min)
|
||||
}
|
||||
if actor == "" && min != "" && got {
|
||||
t.Errorf("empty role allowed method=%s min=%s (should deny)", method, min)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
||||
hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
)
|
||||
|
||||
// TestHooksTracing_EmitHookSpan verifies EmitHookSpan writes a span row with
|
||||
// the canonical name format "hook.<handlerType>.<event>" when a collector
|
||||
// is attached to ctx. No collector → no-op (already covered in unit tests).
|
||||
func TestHooksTracing_EmitHookSpan(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, _ := seedTenantAgent(t, db)
|
||||
|
||||
ts := pg.NewPGTracingStore(db)
|
||||
c := tracing.NewCollector(ts)
|
||||
c.Start()
|
||||
// No defer Stop — tests call Stop explicitly to flush before polling.
|
||||
|
||||
// Seed a parent trace so tenant_id scoping doesn't orphan the span.
|
||||
traceID := uuid.New()
|
||||
if err := c.CreateTrace(tenantCtx(tenantID), &store.TraceData{
|
||||
ID: traceID,
|
||||
Status: store.SpanStatusCompleted,
|
||||
StartTime: time.Now().Add(-5 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTrace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM spans WHERE trace_id = $1", traceID)
|
||||
db.Exec("DELETE FROM traces WHERE id = $1", traceID)
|
||||
})
|
||||
|
||||
ctx := tracing.WithTraceID(tracing.WithCollector(tenantCtx(tenantID), c), traceID)
|
||||
|
||||
started := time.Now().Add(-200 * time.Millisecond)
|
||||
hooks.EmitHookSpan(ctx, hooks.EventPreToolUse, hooks.HandlerHTTP, started, hooks.DecisionAllow, "")
|
||||
|
||||
// Stop collector to flush the buffered span; Stop() drains the chan.
|
||||
c.Stop()
|
||||
|
||||
var name string
|
||||
var durationMS int
|
||||
if err := db.QueryRow(
|
||||
`SELECT name, duration_ms FROM spans WHERE trace_id = $1 LIMIT 1`,
|
||||
traceID,
|
||||
).Scan(&name, &durationMS); err != nil {
|
||||
t.Fatalf("read span: %v", err)
|
||||
}
|
||||
if name != "hook.http.pre_tool_use" {
|
||||
t.Errorf("span name=%q, want hook.http.pre_tool_use", name)
|
||||
}
|
||||
if durationMS <= 0 {
|
||||
t.Errorf("span duration=%d, want positive", durationMS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHooksTracing_DispatcherEmitsSpan runs a full dispatcher Fire with a
|
||||
// collector wired, then asserts a hook-span row appears. Proves tracing +
|
||||
// dispatcher integration end-to-end.
|
||||
func TestHooksTracing_DispatcherEmitsSpan(t *testing.T) {
|
||||
db := testDB(t)
|
||||
tenantID, agentID := seedTenantAgent(t, db)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ts := pg.NewPGTracingStore(db)
|
||||
c := tracing.NewCollector(ts)
|
||||
c.Start()
|
||||
// No defer Stop — tests call Stop explicitly to flush before polling.
|
||||
|
||||
traceID := uuid.New()
|
||||
if err := c.CreateTrace(tenantCtx(tenantID), &store.TraceData{
|
||||
ID: traceID, Status: store.SpanStatusCompleted,
|
||||
StartTime: time.Now().Add(-5 * time.Second),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTrace: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM spans WHERE trace_id = $1", traceID)
|
||||
db.Exec("DELETE FROM traces WHERE id = $1", traceID)
|
||||
})
|
||||
|
||||
hs := pg.NewPGHookStore(db)
|
||||
cfg := hooks.HookConfig{
|
||||
TenantID: tenantID, AgentID: &agentID,
|
||||
Scope: hooks.ScopeAgent, Event: hooks.EventPreToolUse,
|
||||
HandlerType: hooks.HandlerHTTP,
|
||||
Config: map[string]any{"url": srv.URL},
|
||||
TimeoutMS: 5000, OnTimeout: hooks.DecisionAllow,
|
||||
Enabled: true, Version: 1, Source: "api",
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
hookID, err := hs.Create(tenantCtx(tenantID), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", hookID)
|
||||
db.Exec("DELETE FROM agent_hooks WHERE id = $1", hookID)
|
||||
})
|
||||
|
||||
// Wrap ctx with tracing collector + trace ID — dispatcher's emit helper
|
||||
// is invoked from handlers wired via the standard dispatcher.
|
||||
ctx := tracing.WithTraceID(tracing.WithCollector(tenantCtx(tenantID), c), traceID)
|
||||
|
||||
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
||||
hooks.HandlerHTTP: tracedHandler{inner: &hookhandlers.HTTPHandler{Client: srv.Client()}},
|
||||
})
|
||||
|
||||
if _, err := d.Fire(ctx, hooks.Event{
|
||||
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
||||
HookEvent: hooks.EventPreToolUse,
|
||||
}); err != nil {
|
||||
t.Fatalf("Fire: %v", err)
|
||||
}
|
||||
|
||||
// Stop collector to flush the buffered span.
|
||||
c.Stop()
|
||||
|
||||
var rowsCount int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM spans WHERE trace_id = $1 AND name = $2`,
|
||||
traceID, "hook.http.pre_tool_use",
|
||||
).Scan(&rowsCount); err != nil {
|
||||
t.Fatalf("count spans: %v", err)
|
||||
}
|
||||
if rowsCount < 1 {
|
||||
t.Errorf("expected >=1 hook span in DB, got %d", rowsCount)
|
||||
}
|
||||
}
|
||||
|
||||
// tracedHandler wraps an existing hooks.Handler and emits a tracing span
|
||||
// around each call. Production dispatcher does NOT auto-emit spans —
|
||||
// integration with tracing goes through this wrapper pattern (see plan).
|
||||
type tracedHandler struct {
|
||||
inner hooks.Handler
|
||||
}
|
||||
|
||||
func (w tracedHandler) Execute(ctx_ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) {
|
||||
start := time.Now()
|
||||
dec, err := w.inner.Execute(ctx_ctx, cfg, ev)
|
||||
errMsg := ""
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
hooks.EmitHookSpan(ctx_ctx, ev.HookEvent, cfg.HandlerType, start, dec, errMsg)
|
||||
return dec, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user