mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
- Consolidated PG migration 054: agent_hooks rename + junction table + deprecated column drop - Updated SQLite schema v19 with final consolidated migration - Removed obsolete schema rebuild files (v21, v23) - Updated Go store layer (pg/hooks.go, sqlitestore/hooks.go) - Updated integration tests to use new table names (hooks, hook_agents) - Updated TypeScript protocol, UI components, and i18n strings - Updated gateway methods to reflect schema changes Tables: agent_hooks → hooks, agent_hook_agents → hook_agents
324 lines
10 KiB
Go
324 lines
10 KiB
Go
//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 hooks WHERE id = $1", hookID)
|
|
})
|
|
|
|
r, 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 r.Decision != hooks.DecisionAllow {
|
|
t.Errorf("%s: decision=%q, want allow", ev, r.Decision)
|
|
}
|
|
|
|
// 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 hooks WHERE id = $1", hookID)
|
|
})
|
|
|
|
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
|
hooks.HandlerCommand: &hookhandlers.CommandHandler{Edition: edition.Lite},
|
|
})
|
|
|
|
r, 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 r.Decision != hooks.DecisionBlock {
|
|
t.Errorf("decision=%q, want block (exit 2)", r.Decision)
|
|
}
|
|
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 hooks WHERE id = $1", hookID)
|
|
})
|
|
|
|
d := newDispatcher(t, hs, map[hooks.HandlerType]hooks.Handler{
|
|
hooks.HandlerHTTP: &hookhandlers.HTTPHandler{Client: srv.Client()},
|
|
})
|
|
r, err := d.Fire(ctx, hooks.Event{
|
|
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
|
HookEvent: hooks.EventUserPromptSubmit,
|
|
})
|
|
if err != nil || r.Decision != hooks.DecisionAllow {
|
|
t.Errorf("decision=%q err=%v, want allow+nil", r.Decision, 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 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 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},
|
|
})
|
|
r, _ := d.Fire(ctx, hooks.Event{
|
|
EventID: uuid.NewString(), TenantID: tenantID, AgentID: agentID,
|
|
HookEvent: hooks.EventPreToolUse,
|
|
})
|
|
// Blocking event + handler error → fail-closed Block.
|
|
if r.Decision != hooks.DecisionBlock {
|
|
t.Errorf("decision=%q, want block (edition gate)", r.Decision)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|