mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-20 04:23:29 +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
301 lines
9.7 KiB
Go
301 lines
9.7 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks/builtin"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
)
|
|
|
|
// Phase 08 — D bucket: builtin seed reconciliation against live PG.
|
|
//
|
|
// Seed() runs against shared state (the PG hooks table) — these tests
|
|
// share the test DB with other integration tests, so each one purges the
|
|
// seeded rows in t.Cleanup. Tests run sequentially via the shared DB lock.
|
|
|
|
const seededBuiltinName = "pii-redactor"
|
|
|
|
// loadBuiltins ensures the YAML registry is parsed; safe to call repeatedly.
|
|
func loadBuiltins(t *testing.T) {
|
|
t.Helper()
|
|
if err := builtin.Load(); err != nil {
|
|
t.Fatalf("builtin.Load: %v", err)
|
|
}
|
|
}
|
|
|
|
// purgeBuiltinRows removes any seeded builtin rows for the given builtin id —
|
|
// matches the BuiltinEventID(name, ev) pattern. Idempotent.
|
|
func purgeBuiltinRows(t *testing.T, name string) {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
for _, ev := range builtinEventList(t, name) {
|
|
id := builtin.BuiltinEventID(name, ev)
|
|
db.Exec("DELETE FROM hook_executions WHERE hook_id = $1", id)
|
|
db.Exec("DELETE FROM hooks WHERE id = $1", id)
|
|
}
|
|
}
|
|
|
|
// builtinEventList returns the events the named builtin claims in YAML.
|
|
func builtinEventList(t *testing.T, name string) []string {
|
|
t.Helper()
|
|
for _, s := range builtin.RegisteredSpecs() {
|
|
if s.ID == name {
|
|
return s.Events
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// test-D1: fresh seed inserts one row per event with stable UUIDv5 IDs.
|
|
func TestHooksD1_SeedInsertsStableUUIDsPerEvent(t *testing.T) {
|
|
loadBuiltins(t)
|
|
purgeBuiltinRows(t, seededBuiltinName)
|
|
t.Cleanup(func() { purgeBuiltinRows(t, seededBuiltinName) })
|
|
|
|
hs := pg.NewPGHookStore(testDB(t))
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed: %v", err)
|
|
}
|
|
|
|
events := builtinEventList(t, seededBuiltinName)
|
|
if len(events) == 0 {
|
|
t.Fatalf("no events registered for %q", seededBuiltinName)
|
|
}
|
|
for _, ev := range events {
|
|
expectedID := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
got, err := hs.GetByID(crossTenantOwnerCtx(), expectedID)
|
|
if err != nil {
|
|
t.Fatalf("GetByID %s/%s: %v", seededBuiltinName, ev, err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("missing row for %s/%s (id=%s)", seededBuiltinName, ev, expectedID)
|
|
}
|
|
if got.Source != hooks.SourceBuiltin {
|
|
t.Errorf("source=%q, want builtin", got.Source)
|
|
}
|
|
if got.HandlerType != hooks.HandlerScript {
|
|
t.Errorf("handler=%q, want script", got.HandlerType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// test-D1b (idempotency): seed ran twice produces no extra rows. Without H9
|
|
// (cfg.ID honor) this would double the row count on each boot.
|
|
func TestHooksD1b_SeedIdempotent(t *testing.T) {
|
|
loadBuiltins(t)
|
|
purgeBuiltinRows(t, seededBuiltinName)
|
|
t.Cleanup(func() { purgeBuiltinRows(t, seededBuiltinName) })
|
|
|
|
hs := pg.NewPGHookStore(testDB(t))
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed first: %v", err)
|
|
}
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed second: %v", err)
|
|
}
|
|
|
|
events := builtinEventList(t, seededBuiltinName)
|
|
for _, ev := range events {
|
|
id := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
row, err := hs.GetByID(crossTenantOwnerCtx(), id)
|
|
if err != nil || row == nil {
|
|
t.Fatalf("missing row after second seed %s/%s: err=%v", seededBuiltinName, ev, err)
|
|
}
|
|
}
|
|
// Cross-check the COUNT — only one row per (id) should exist (PK guard).
|
|
var n int
|
|
if err := testDB(t).QueryRow(
|
|
`SELECT COUNT(*) FROM hooks WHERE source = 'builtin'`,
|
|
).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if n != len(events) {
|
|
t.Errorf("builtin rows count=%d, want %d", n, len(events))
|
|
}
|
|
}
|
|
|
|
// test-D2: when DB has an older metadata.version than the embedded spec, a
|
|
// re-seed UPDATEs each row. We simulate this by directly downgrading the
|
|
// metadata.version column post-seed, then re-seeding and checking the bump.
|
|
func TestHooksD2_VersionBumpUpdatesRows(t *testing.T) {
|
|
loadBuiltins(t)
|
|
purgeBuiltinRows(t, seededBuiltinName)
|
|
t.Cleanup(func() { purgeBuiltinRows(t, seededBuiltinName) })
|
|
|
|
db := testDB(t)
|
|
hs := pg.NewPGHookStore(db)
|
|
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed initial: %v", err)
|
|
}
|
|
events := builtinEventList(t, seededBuiltinName)
|
|
|
|
// Force-downgrade metadata.version to 0 for every seeded row.
|
|
for _, ev := range events {
|
|
id := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
_, err := db.Exec(
|
|
`UPDATE hooks SET metadata = jsonb_set(metadata, '{version}', '0'::jsonb) WHERE id = $1`,
|
|
id,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("downgrade %s/%s: %v", seededBuiltinName, ev, err)
|
|
}
|
|
}
|
|
|
|
// Re-seed: embedVersion (≥1) > dbVersion (0) → triggers Update.
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed re-run: %v", err)
|
|
}
|
|
|
|
// Confirm metadata.version was bumped back up by the seeder.
|
|
for _, ev := range events {
|
|
id := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
row, err := hs.GetByID(crossTenantOwnerCtx(), id)
|
|
if err != nil || row == nil {
|
|
t.Fatalf("GetByID after bump %s/%s: err=%v row=%v", seededBuiltinName, ev, err, row)
|
|
}
|
|
v := jsonNumber(row.Metadata["version"])
|
|
if v == 0 {
|
|
t.Errorf("metadata.version still 0 after bump (row=%s/%s)", seededBuiltinName, ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// test-D3: when DB version > embed version, seeder logs WARN and leaves the
|
|
// DB row alone (no rollback). We simulate by setting metadata.version=99 and
|
|
// asserting the row stays at 99 after re-seed.
|
|
func TestHooksD3_DowngradeSkippedWithWarning(t *testing.T) {
|
|
loadBuiltins(t)
|
|
purgeBuiltinRows(t, seededBuiltinName)
|
|
t.Cleanup(func() { purgeBuiltinRows(t, seededBuiltinName) })
|
|
|
|
db := testDB(t)
|
|
hs := pg.NewPGHookStore(db)
|
|
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed initial: %v", err)
|
|
}
|
|
events := builtinEventList(t, seededBuiltinName)
|
|
for _, ev := range events {
|
|
id := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
if _, err := db.Exec(
|
|
`UPDATE hooks SET metadata = jsonb_set(metadata, '{version}', '99'::jsonb) WHERE id = $1`,
|
|
id,
|
|
); err != nil {
|
|
t.Fatalf("upgrade: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed re-run: %v", err)
|
|
}
|
|
|
|
for _, ev := range events {
|
|
id := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
row, _ := hs.GetByID(crossTenantOwnerCtx(), id)
|
|
if v := jsonNumber(row.Metadata["version"]); v != 99 {
|
|
t.Errorf("metadata.version=%d after downgrade-skip; want 99 (no rollback)", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// test-D4: BuiltinDisable list flips the enabled flag. Subset of disable list
|
|
// containing the seeded builtin name forces enabled=false on the row.
|
|
func TestHooksD4_BuiltinDisableListForcesOff(t *testing.T) {
|
|
loadBuiltins(t)
|
|
purgeBuiltinRows(t, seededBuiltinName)
|
|
t.Cleanup(func() { purgeBuiltinRows(t, seededBuiltinName) })
|
|
|
|
hs := pg.NewPGHookStore(testDB(t))
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{
|
|
BuiltinDisable: []string{seededBuiltinName},
|
|
}); err != nil {
|
|
t.Fatalf("Seed: %v", err)
|
|
}
|
|
|
|
for _, ev := range builtinEventList(t, seededBuiltinName) {
|
|
id := builtin.BuiltinEventID(seededBuiltinName, ev)
|
|
row, err := hs.GetByID(crossTenantOwnerCtx(), id)
|
|
if err != nil || row == nil {
|
|
t.Fatalf("GetByID %s/%s: err=%v row=%v", seededBuiltinName, ev, err, row)
|
|
}
|
|
if row.Enabled {
|
|
t.Errorf("row %s/%s enabled despite builtin_disable list", seededBuiltinName, ev)
|
|
}
|
|
}
|
|
}
|
|
|
|
// test-D5: store-level builtin protection — Update with non-enabled patch
|
|
// fails ErrBuiltinReadOnly under user ctx; Update with enabled-only patch
|
|
// succeeds. Delete fails. SeedBypass ctx makes everything succeed.
|
|
func TestHooksD5_BuiltinReadonlyProtection(t *testing.T) {
|
|
loadBuiltins(t)
|
|
purgeBuiltinRows(t, seededBuiltinName)
|
|
t.Cleanup(func() { purgeBuiltinRows(t, seededBuiltinName) })
|
|
|
|
hs := pg.NewPGHookStore(testDB(t))
|
|
if err := builtin.Seed(context.Background(), hs, config.HooksConfig{}); err != nil {
|
|
t.Fatalf("Seed: %v", err)
|
|
}
|
|
events := builtinEventList(t, seededBuiltinName)
|
|
if len(events) == 0 {
|
|
t.Fatalf("no events for %q", seededBuiltinName)
|
|
}
|
|
id := builtin.BuiltinEventID(seededBuiltinName, events[0])
|
|
|
|
userCtx := crossTenantOwnerCtx() // master scope, NOT seed bypass
|
|
|
|
// (a) non-enabled patch must reject.
|
|
err := hs.Update(userCtx, id, map[string]any{"matcher": "x"})
|
|
if !errors.Is(err, hooks.ErrBuiltinReadOnly) {
|
|
t.Errorf("Update matcher: err=%v, want ErrBuiltinReadOnly", err)
|
|
}
|
|
// (b) enabled-only patch must succeed.
|
|
if err := hs.Update(userCtx, id, map[string]any{"enabled": false}); err != nil {
|
|
t.Errorf("Update enabled toggle: %v", err)
|
|
}
|
|
// (c) Delete must reject.
|
|
if err := hs.Delete(userCtx, id); !errors.Is(err, hooks.ErrBuiltinReadOnly) {
|
|
t.Errorf("Delete: err=%v, want ErrBuiltinReadOnly", err)
|
|
}
|
|
// (d) Seed-bypass ctx allows wider Update + Delete.
|
|
bypassCtx := hooks.WithSeedBypass(userCtx)
|
|
if err := hs.Update(bypassCtx, id, map[string]any{"matcher": "y"}); err != nil {
|
|
t.Errorf("Update under bypass: %v", err)
|
|
}
|
|
if err := hs.Delete(bypassCtx, id); err != nil {
|
|
t.Errorf("Delete under bypass: %v", err)
|
|
}
|
|
}
|
|
|
|
// jsonNumber extracts an int from a metadata value (PG JSONB decodes ints to
|
|
// float64 when round-tripped through json.Unmarshal).
|
|
func jsonNumber(v any) int {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case json.Number:
|
|
i, _ := n.Int64()
|
|
return int(i)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// _ asserts uuid is referenced — keeps the import explicit for tests adding
|
|
// fixtures with manual IDs in the future.
|
|
var _ = uuid.Nil
|