//go:build integration package integration import ( "context" "strings" "testing" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" "github.com/nextlevelbuilder/goclaw/internal/hooks" "github.com/nextlevelbuilder/goclaw/internal/hooks/builtin" hookhandlers "github.com/nextlevelbuilder/goclaw/internal/hooks/handlers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" ) // Phase 08 — F bucket: WS end-to-end equivalence. // // The repo does not yet ship an in-process WS server bootstrap helper, so // these tests exercise the SAME code paths the WS handlers invoke: // - Validate() (ConfigConfig.Validate runs the edition gate + handler rules) // - PGHookStore.Create + ResolveForEvent (live PG) // - DispatcherTestRunner.RunTest (the hooks.test method's underlying runner) // - Connect-response equivalence: edition.Current().Name + IsMasterScope // // When/if a real WS bootstrap arrives, F1-F4 should be re-implemented over // it. Until then this is the closest thing to a wire-level guarantee // without forking the gateway init flow. // test-F1: master-scope ctx + Standard edition → IsMasterScope=true, // edition.Current().Name="standard". Equivalent to the values returned in // the WS connect response shape (`is_master_scope`, `edition`). func TestHooksF1_ConnectShapeMasterStandard(t *testing.T) { withEdition(t, edition.Standard) ctx := crossTenantOwnerCtx() if !store.IsMasterScope(ctx) { t.Errorf("master ctx → IsMasterScope=false; want true") } if got := edition.Current().Name; got != edition.Standard.Name { t.Errorf("edition=%q, want %q", got, edition.Standard.Name) } } // test-F2: tenant ctx + Standard edition → IsMasterScope=false, // edition.Current().Name="standard". func TestHooksF2_ConnectShapeTenantStandard(t *testing.T) { withEdition(t, edition.Standard) tenantA, _ := seedTenantAgent(t, testDB(t)) ctx := tenantCtx(tenantA) if store.IsMasterScope(ctx) { t.Errorf("tenant ctx → IsMasterScope=true; want false") } if got := edition.Current().Name; got != edition.Standard.Name { t.Errorf("edition=%q, want %q", got, edition.Standard.Name) } } // test-F3: operator ctx + Lite edition → IsMasterScope=false (RBAC role does // not affect master-scope flag), edition.Current().Name="lite". func TestHooksF3_ConnectShapeOperatorLite(t *testing.T) { withEdition(t, edition.Lite) tenantA, _ := seedTenantAgent(t, testDB(t)) ctx := tenantCtx(tenantA) // Operator role on Lite — operator does not flip IsMasterScope. ctx = store.WithRole(ctx, "operator") if store.IsMasterScope(ctx) { t.Errorf("operator ctx → IsMasterScope=true; want false") } if got := edition.Current().Name; got != edition.Lite.Name { t.Errorf("edition=%q, want %q", got, edition.Lite.Name) } } // test-F4: tenant admin creating a script hook (handler_type='script', // scope='tenant', valid JS) succeeds end-to-end through the same Validate + // Create + ResolveForEvent + TestRunner pipeline that hooks.create / list / // test invoke. func TestHooksF4_TenantAdminCreatesScriptHook(t *testing.T) { withEdition(t, edition.Standard) db := testDB(t) tenantA, agentA := seedTenantAgent(t, db) hs := pg.NewPGHookStore(db) cfg := hookCfgScript(tenantA, hooks.EventUserPromptSubmit, "ui", `function handle(e) { return {decision: "allow", reason: "ok"}; }`) if err := cfg.Validate(edition.Standard, false); err != nil { t.Fatalf("Validate: %v", err) } id, err := hs.Create(tenantCtx(tenantA), cfg) if err != nil { t.Fatalf("Create: %v", err) } t.Cleanup(func() { purgeAgentHookByID(t, db, id) }) // hooks.list equivalent — confirm row appears under tenant ctx. rows, err := hs.List(tenantCtx(tenantA), hooks.ListFilter{}) if err != nil { t.Fatalf("List: %v", err) } found := false for _, r := range rows { if r.ID == id { found = true } } if !found { t.Errorf("list did not include newly-created hook %s", id) } // hooks.test equivalent — DispatcherTestRunner with script handler. runner := methods.NewDispatcherTestRunner(map[hooks.HandlerType]hooks.Handler{ hooks.HandlerScript: hookhandlers.NewScriptHandler(4, 2, 32), }) res := runner.RunTest(context.Background(), cfg, hooks.Event{ EventID: "f4", TenantID: tenantA, AgentID: agentA, HookEvent: hooks.EventUserPromptSubmit, }) if res.Decision != hooks.DecisionAllow { t.Errorf("test result decision=%v, want allow", res.Decision) } } // test-F5: handler_type='command' on Standard edition → Validate rejects. // Mirrors the WS handler's pre-Create gate. func TestHooksF5_CommandHandlerRejectedOnStandard(t *testing.T) { withEdition(t, edition.Standard) db := testDB(t) tenantA, _ := seedTenantAgent(t, db) cfg := hooks.HookConfig{ TenantID: tenantA, Scope: hooks.ScopeTenant, Event: hooks.EventPreToolUse, HandlerType: hooks.HandlerCommand, Config: map[string]any{"command": "echo ok"}, TimeoutMS: 2000, OnTimeout: hooks.DecisionAllow, Enabled: true, Source: "ui", } err := cfg.Validate(edition.Standard, false) if err == nil { t.Fatal("Standard Validate accepted command handler; want rejection") } if !strings.Contains(err.Error(), "command") { t.Errorf("error should mention 'command' handler: %v", err) } // Sanity: Lite accepts the same config (regression guard). if err := cfg.Validate(edition.Lite, false); err != nil { t.Errorf("Lite Validate rejected command handler: %v", err) } } // test-F6: hooks.test invoked with the pii-redactor builtin + sample event // containing an email → result.UpdatedInput.rawInput contains // [REDACTED_EMAIL]. This is the WS test panel's golden assertion. func TestHooksF6_PIIRedactorTestPanel(t *testing.T) { withEdition(t, edition.Standard) if err := builtin.Load(); err != nil { t.Fatalf("builtin.Load: %v", err) } src, err := builtin.Source("pii-redactor.js") if err != nil { t.Fatalf("Source: %v", err) } cfg := hookCfgScript(hooks.SentinelTenantID, hooks.EventUserPromptSubmit, hooks.SourceBuiltin, string(src)) cfg.Scope = hooks.ScopeGlobal cfg.ID = builtin.BuiltinEventID("pii-redactor", string(hooks.EventUserPromptSubmit)) cfg.TimeoutMS = 2000 cfg.Priority = 900 runner := methods.NewDispatcherTestRunner(map[hooks.HandlerType]hooks.Handler{ hooks.HandlerScript: hookhandlers.NewScriptHandler(4, 2, 32), }) // DispatcherTestRunner.RunTest does NOT populate HookTestResult.UpdatedInput // (it only forwards Decision / Reason / Error / DurationMS) per // hooks_test_runner.go. The mutation surface lives on the dispatcher path // (FireResult.UpdatedRawInput) — already covered by TestHooksC2. // Here we instead exercise the runner's decision pathway end-to-end with // the pii-redactor JS so we catch any wiring break in the WS test panel // (the builtin returns decision=allow whether or not the input contains // PII, so a regression where the sandbox blocks the script would surface // as decision=error here). res := runner.RunTest(context.Background(), cfg, hooks.Event{ EventID: "f6", TenantID: hooks.SentinelTenantID, AgentID: uuid.New(), HookEvent: hooks.EventUserPromptSubmit, RawInput: "ping me at user@example.com", }) if res.Decision != hooks.DecisionAllow { t.Fatalf("pii-redactor test result decision=%v error=%q; want allow", res.Decision, res.Error) } }