mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-08 06:17:26 +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
474 lines
14 KiB
Go
474 lines
14 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-migrate/migrate/v4"
|
|
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
|
"github.com/google/uuid"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ─── test DB setup ───────────────────────────────────────────────────────────
|
|
|
|
func hooksTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skipf("TEST_DATABASE_URL not set; skipping PG hook store tests")
|
|
}
|
|
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open DB: %v", err)
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
t.Skipf("PG not reachable: %v", err)
|
|
}
|
|
|
|
m, err := migrate.New("file://../../../migrations", dsn)
|
|
if err != nil {
|
|
db.Close()
|
|
t.Fatalf("migrate.New: %v", err)
|
|
}
|
|
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
|
db.Close()
|
|
t.Fatalf("migrate up: %v", err)
|
|
}
|
|
m.Close()
|
|
|
|
InitSqlx(db)
|
|
t.Cleanup(func() { db.Close() })
|
|
return db
|
|
}
|
|
|
|
// seedTenantAndAgent inserts minimal tenant + agent rows and registers cleanup.
|
|
func seedTenantAndAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
|
|
t.Helper()
|
|
tenantID = uuid.Must(uuid.NewV7())
|
|
agentID = uuid.Must(uuid.NewV7())
|
|
|
|
// UUIDv7s generated in the same millisecond share their 8-char prefix,
|
|
// so deriving slug/agent_key from `[:8]` collides when a test calls
|
|
// seedTenantAndAgent twice back-to-back and the second INSERT is swallowed
|
|
// by ON CONFLICT DO NOTHING on the unique slug/agent_key constraint. Use
|
|
// the full UUID — always unique.
|
|
_, err := db.Exec(
|
|
`INSERT INTO tenants (id, name, slug, status) VALUES ($1,$2,$3,'active') ON CONFLICT DO NOTHING`,
|
|
tenantID, "hook-test-"+tenantID.String()[:8], "ht-"+tenantID.String())
|
|
if err != nil {
|
|
t.Fatalf("seed tenant: %v", err)
|
|
}
|
|
_, err = db.Exec(
|
|
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
|
VALUES ($1,$2,$3,'predefined','active','test','test-model','owner') ON CONFLICT DO NOTHING`,
|
|
agentID, tenantID, "hook-agent-"+agentID.String())
|
|
if err != nil {
|
|
t.Fatalf("seed agent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Exec("DELETE FROM hook_executions WHERE hook_id IN (SELECT id FROM hooks WHERE tenant_id=$1)", tenantID)
|
|
db.Exec("DELETE FROM hooks WHERE tenant_id=$1", tenantID)
|
|
db.Exec("DELETE FROM agents WHERE id=$1", agentID)
|
|
db.Exec("DELETE FROM tenants WHERE id=$1", tenantID)
|
|
})
|
|
return tenantID, agentID
|
|
}
|
|
|
|
func masterCtx() context.Context {
|
|
return store.WithTenantID(context.Background(), store.MasterTenantID)
|
|
}
|
|
|
|
func tenantScopedCtx(tenantID uuid.UUID) context.Context {
|
|
return store.WithTenantID(context.Background(), tenantID)
|
|
}
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func minimalHook(tenantID uuid.UUID, event hooks.HookEvent) hooks.HookConfig {
|
|
return hooks.HookConfig{
|
|
TenantID: tenantID,
|
|
Event: event,
|
|
HandlerType: hooks.HandlerCommand,
|
|
Scope: hooks.ScopeTenant,
|
|
Config: map[string]any{"cmd": "echo test"},
|
|
Metadata: map[string]any{},
|
|
TimeoutMS: 5000,
|
|
OnTimeout: hooks.DecisionBlock,
|
|
Source: "api",
|
|
Enabled: true,
|
|
Priority: 0,
|
|
}
|
|
}
|
|
|
|
// ─── CRUD ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestPGHookStore_CRUD(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantID, _ := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
ctx := tenantScopedCtx(tenantID)
|
|
|
|
// Create
|
|
cfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
|
id, err := s.Create(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if id == uuid.Nil {
|
|
t.Fatal("Create returned nil UUID")
|
|
}
|
|
|
|
// GetByID
|
|
got, err := s.GetByID(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetByID: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("GetByID returned nil for existing hook")
|
|
}
|
|
if got.Event != hooks.EventPreToolUse {
|
|
t.Errorf("event mismatch: got %q want %q", got.Event, hooks.EventPreToolUse)
|
|
}
|
|
if got.TenantID != tenantID {
|
|
t.Errorf("tenant_id mismatch")
|
|
}
|
|
if got.Version != 1 {
|
|
t.Errorf("initial version should be 1, got %d", got.Version)
|
|
}
|
|
|
|
// GetByID — not found returns nil, nil
|
|
missing, err := s.GetByID(ctx, uuid.Must(uuid.NewV7()))
|
|
if err != nil {
|
|
t.Fatalf("GetByID(missing): unexpected error %v", err)
|
|
}
|
|
if missing != nil {
|
|
t.Fatal("GetByID(missing): expected nil")
|
|
}
|
|
|
|
// Update — bumps version
|
|
if err := s.Update(ctx, id, map[string]any{"priority": 10}); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
updated, _ := s.GetByID(ctx, id)
|
|
if updated.Priority != 10 {
|
|
t.Errorf("priority not updated: got %d", updated.Priority)
|
|
}
|
|
if updated.Version != 2 {
|
|
t.Errorf("version should be 2 after update, got %d", updated.Version)
|
|
}
|
|
|
|
// Update — reject version key
|
|
if err := s.Update(ctx, id, map[string]any{"version": 99}); err == nil {
|
|
t.Fatal("Update with 'version' key should return error")
|
|
}
|
|
|
|
// Delete
|
|
if err := s.Delete(ctx, id); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
deleted, err := s.GetByID(ctx, id)
|
|
if err != nil || deleted != nil {
|
|
t.Fatalf("GetByID after Delete: want (nil,nil), got (%v,%v)", deleted, err)
|
|
}
|
|
}
|
|
|
|
// ─── Tenant isolation ─────────────────────────────────────────────────────────
|
|
|
|
func TestPGHookStore_TenantIsolation(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantA, _ := seedTenantAndAgent(t, db)
|
|
tenantB, _ := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
|
|
ctxA := tenantScopedCtx(tenantA)
|
|
ctxB := tenantScopedCtx(tenantB)
|
|
|
|
cfgA := minimalHook(tenantA, hooks.EventStop)
|
|
idA, err := s.Create(ctxA, cfgA)
|
|
if err != nil {
|
|
t.Fatalf("Create A: %v", err)
|
|
}
|
|
|
|
// Tenant B cannot read tenant A's hook
|
|
got, err := s.GetByID(ctxB, idA)
|
|
// GetByID has no tenant filter — returns the row regardless of scope.
|
|
// List should filter it out though.
|
|
_ = got
|
|
_ = err
|
|
|
|
// List from tenant B should not see tenant A's hooks.
|
|
listB, err := s.List(ctxB, hooks.ListFilter{})
|
|
if err != nil {
|
|
t.Fatalf("List B: %v", err)
|
|
}
|
|
for _, h := range listB {
|
|
if h.ID == idA {
|
|
t.Errorf("tenant B saw tenant A hook %s", idA)
|
|
}
|
|
}
|
|
|
|
// Tenant A can see their own hook.
|
|
listA, err := s.List(ctxA, hooks.ListFilter{})
|
|
if err != nil {
|
|
t.Fatalf("List A: %v", err)
|
|
}
|
|
found := false
|
|
for _, h := range listA {
|
|
if h.ID == idA {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("tenant A did not see their own hook in List")
|
|
}
|
|
|
|
// Tenant B cannot delete tenant A's hook.
|
|
if err := s.Delete(ctxB, idA); err == nil {
|
|
t.Error("tenant B should not be able to delete tenant A's hook")
|
|
}
|
|
// Cleanup.
|
|
_ = s.Delete(ctxA, idA)
|
|
}
|
|
|
|
// ─── ResolveForEvent ─────────────────────────────────────────────────────────
|
|
|
|
func TestPGHookStore_ResolveForEvent(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantID, agentID := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
ctx := tenantScopedCtx(tenantID)
|
|
|
|
// Create two enabled hooks for pre_tool_use, different priorities.
|
|
lowPriCfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
|
lowPriCfg.Priority = 0
|
|
highPriCfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
|
highPriCfg.Priority = 10
|
|
|
|
id1, _ := s.Create(ctx, lowPriCfg)
|
|
id2, _ := s.Create(ctx, highPriCfg)
|
|
t.Cleanup(func() {
|
|
s.Delete(masterCtx(), id1)
|
|
s.Delete(masterCtx(), id2)
|
|
})
|
|
|
|
event := hooks.Event{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
HookEvent: hooks.EventPreToolUse,
|
|
}
|
|
resolved, err := s.ResolveForEvent(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("ResolveForEvent: %v", err)
|
|
}
|
|
if len(resolved) < 2 {
|
|
t.Fatalf("expected >=2 hooks, got %d", len(resolved))
|
|
}
|
|
// First should be highest priority.
|
|
if resolved[0].Priority < resolved[1].Priority {
|
|
t.Errorf("hooks not ordered by priority DESC: [0]=%d [1]=%d",
|
|
resolved[0].Priority, resolved[1].Priority)
|
|
}
|
|
|
|
// Disabled hook should not appear.
|
|
disabledCfg := minimalHook(tenantID, hooks.EventPreToolUse)
|
|
disabledCfg.Enabled = false
|
|
idDisabled, _ := s.Create(ctx, disabledCfg)
|
|
t.Cleanup(func() { s.Delete(masterCtx(), idDisabled) })
|
|
|
|
resolved2, _ := s.ResolveForEvent(ctx, event)
|
|
for _, h := range resolved2 {
|
|
if h.ID == idDisabled {
|
|
t.Error("disabled hook appeared in ResolveForEvent")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── WriteExecution ──────────────────────────────────────────────────────────
|
|
|
|
func TestPGHookStore_WriteExecution(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantID, _ := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
ctx := tenantScopedCtx(tenantID)
|
|
|
|
// Create a hook for FK.
|
|
cfg := minimalHook(tenantID, hooks.EventStop)
|
|
hookID, err := s.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)
|
|
s.Delete(masterCtx(), hookID)
|
|
})
|
|
|
|
execID := uuid.Must(uuid.NewV7())
|
|
dedup := "test-dedup-" + execID.String()[:8]
|
|
exec := hooks.HookExecution{
|
|
ID: execID,
|
|
HookID: &hookID,
|
|
SessionID: "sess-123",
|
|
Event: hooks.EventStop,
|
|
InputHash: "aaabbbccc",
|
|
Decision: hooks.DecisionAllow,
|
|
DurationMS: 42,
|
|
DedupKey: dedup,
|
|
Metadata: map[string]any{"test": true},
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
// First write should succeed.
|
|
if err := s.WriteExecution(ctx, exec); err != nil {
|
|
t.Fatalf("WriteExecution: %v", err)
|
|
}
|
|
|
|
// Duplicate dedup_key should be silently ignored (idempotent).
|
|
if err := s.WriteExecution(ctx, exec); err != nil {
|
|
t.Fatalf("WriteExecution (dedup): %v", err)
|
|
}
|
|
|
|
// Verify the row exists.
|
|
var count int
|
|
if err := db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM hook_executions WHERE dedup_key=$1", dedup,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count executions: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("expected 1 execution row, got %d", count)
|
|
}
|
|
}
|
|
|
|
// ─── Cache invalidation ───────────────────────────────────────────────────────
|
|
|
|
func TestPGHookStore_CacheInvalidatedOnWrite(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantID, agentID := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
ctx := tenantScopedCtx(tenantID)
|
|
|
|
event := hooks.Event{
|
|
TenantID: tenantID,
|
|
AgentID: agentID,
|
|
HookEvent: hooks.EventSessionStart,
|
|
}
|
|
|
|
// Resolve populates cache.
|
|
before, err := s.ResolveForEvent(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("ResolveForEvent: %v", err)
|
|
}
|
|
beforeCount := len(before)
|
|
|
|
// Add a hook — cache should be invalidated.
|
|
cfg := minimalHook(tenantID, hooks.EventSessionStart)
|
|
id, err := s.Create(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Delete(masterCtx(), id) })
|
|
|
|
after, err := s.ResolveForEvent(ctx, event)
|
|
if err != nil {
|
|
t.Fatalf("ResolveForEvent after create: %v", err)
|
|
}
|
|
if len(after) <= beforeCount {
|
|
t.Errorf("expected more hooks after create: before=%d after=%d", beforeCount, len(after))
|
|
}
|
|
}
|
|
|
|
// H9 (Phase 03): Create must honor a caller-supplied cfg.ID so the builtin
|
|
// seeder's idempotent UUIDv5 keys survive restarts and tests can use
|
|
// deterministic IDs. Verified on both Create paths (fixed + auto).
|
|
func TestPGHookStore_CreateHonorsFixedID(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantID, _ := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
ctx := tenantScopedCtx(tenantID)
|
|
|
|
fixed := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
|
cfg := minimalHook(tenantID, hooks.EventUserPromptSubmit)
|
|
cfg.ID = fixed
|
|
|
|
got, err := s.Create(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("Create fixed id: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Delete(masterCtx(), got) })
|
|
if got != fixed {
|
|
t.Fatalf("returned id=%s, want %s (H9: caller id must be honored)", got, fixed)
|
|
}
|
|
|
|
// Nil cfg.ID still auto-generates a UUIDv7.
|
|
cfg2 := minimalHook(tenantID, hooks.EventPreToolUse)
|
|
cfg2.ID = uuid.Nil
|
|
auto, err := s.Create(ctx, cfg2)
|
|
if err != nil {
|
|
t.Fatalf("Create auto id: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Delete(masterCtx(), auto) })
|
|
if auto == uuid.Nil {
|
|
t.Fatal("Create returned nil id for cfg.ID=uuid.Nil path")
|
|
}
|
|
}
|
|
|
|
// ─── Phase 04: builtin-row readonly protection ───────────────────────────────
|
|
|
|
// TestPGHookStore_BuiltinReadOnly exercises the Phase 04 guard: user-facing
|
|
// writes on a source='builtin' row may only toggle enabled; every other patch
|
|
// must surface ErrBuiltinReadOnly. The seed bypass marker unlocks full writes
|
|
// for the loader package only.
|
|
func TestPGHookStore_BuiltinReadOnly(t *testing.T) {
|
|
db := hooksTestDB(t)
|
|
tenantID, _ := seedTenantAndAgent(t, db)
|
|
s := NewPGHookStore(db)
|
|
|
|
// Seed a builtin row via WithSeedBypass (the only authorized path).
|
|
seedCtx := hooks.WithSeedBypass(store.WithRole(masterCtx(), store.RoleOwner))
|
|
cfg := minimalHook(hooks.SentinelTenantID, hooks.EventUserPromptSubmit)
|
|
cfg.Source = hooks.SourceBuiltin
|
|
cfg.Scope = hooks.ScopeGlobal
|
|
cfg.HandlerType = hooks.HandlerScript
|
|
cfg.Config = map[string]any{"source": "// v1"}
|
|
fixedID := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
|
cfg.ID = fixedID
|
|
id, err := s.Create(seedCtx, cfg)
|
|
if err != nil {
|
|
t.Fatalf("seed create: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Delete(seedCtx, id) })
|
|
|
|
// User ctx: tenant-scoped. Non-enabled patch must be rejected.
|
|
userCtx := tenantScopedCtx(tenantID)
|
|
err = s.Update(userCtx, id, map[string]any{"matcher": "evil"})
|
|
if !errors.Is(err, hooks.ErrBuiltinReadOnly) {
|
|
t.Fatalf("Update(matcher) err=%v, want ErrBuiltinReadOnly", err)
|
|
}
|
|
|
|
// Enabled toggle through master context is allowed.
|
|
if err := s.Update(masterCtx(), id, map[string]any{"enabled": false}); err != nil {
|
|
t.Fatalf("Update(enabled) should succeed on builtin: %v", err)
|
|
}
|
|
|
|
// Delete blocked for users.
|
|
if err := s.Delete(userCtx, id); !errors.Is(err, hooks.ErrBuiltinReadOnly) {
|
|
t.Fatalf("Delete user err=%v, want ErrBuiltinReadOnly", err)
|
|
}
|
|
|
|
// Seed bypass unlocks full writes (round-trip to prove).
|
|
if err := s.Update(seedCtx, id, map[string]any{"matcher": "ok"}); err != nil {
|
|
t.Fatalf("seed-bypass Update should succeed: %v", err)
|
|
}
|
|
}
|