mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-04 04:17:56 +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
262 lines
8.2 KiB
Go
262 lines
8.2 KiB
Go
//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 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 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 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 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 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)
|
||
}
|
||
}
|
||
}
|
||
}
|