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:
viettranx committed 2026-04-16 14:17:47 +07:00
1 parent 727f345344
commit f90f11a9f9
5 files changed
+1077

No files matched your search

+314
View File
@@ -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
+323
View File
@@ -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)
}
+12
View File
@@ -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")
+261
View File
@@ -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)
}
}
}
}
+167
View File
@@ -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
}