Files

368 lines
12 KiB
Go

package hooks_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// baseValidCommandHook returns a HookConfig that passes Validate on the Lite
// edition. Tests override fields to trigger specific failure modes.
func baseValidCommandHook() hooks.HookConfig {
return hooks.HookConfig{
ID: uuid.New(),
TenantID: hooks.SentinelTenantID,
Event: hooks.EventPreToolUse,
HandlerType: hooks.HandlerCommand,
Scope: hooks.ScopeGlobal,
Config: map[string]any{"command": "/bin/true"},
Matcher: "^Write$",
IfExpr: `tool_name == "Write"`,
TimeoutMS: 5000,
OnTimeout: hooks.DecisionBlock,
Priority: 100,
Enabled: true,
Version: 1,
Source: "ui",
}
}
func TestValidate_AcceptsValidCommandHook(t *testing.T) {
h := baseValidCommandHook()
if err := h.Validate(edition.Lite, false); err != nil {
t.Fatalf("expected nil error; got %v", err)
}
}
func TestValidate_AcceptsValidHTTPHook(t *testing.T) {
h := baseValidCommandHook()
h.HandlerType = hooks.HandlerHTTP
h.Config = map[string]any{"url": "https://example.com/hook"}
h.Matcher = "" // http hook without matcher is allowed (matches all tools)
h.IfExpr = ""
if err := h.Validate(edition.Standard, false); err != nil {
t.Fatalf("expected nil error; got %v", err)
}
}
func TestValidate_AcceptsValidPromptHook(t *testing.T) {
h := baseValidCommandHook()
h.HandlerType = hooks.HandlerPrompt
h.Config = map[string]any{"prompt_template": "Review this action"}
h.Matcher = "^Write$"
h.IfExpr = ""
if err := h.Validate(edition.Standard, false); err != nil {
t.Fatalf("expected nil error; got %v", err)
}
}
// Prompt handler requires at least ONE filter (matcher OR if_expr) to avoid
// firing the LLM on every event — runaway-cost mitigation (L-risk in plan).
func TestValidate_RejectsPromptWithoutFilter(t *testing.T) {
h := baseValidCommandHook()
h.HandlerType = hooks.HandlerPrompt
h.Config = map[string]any{"template": "x"}
h.Matcher = ""
h.IfExpr = ""
err := h.Validate(edition.Standard, false)
if err == nil {
t.Fatal("expected validation error for prompt without matcher/if_expr")
}
if !strings.Contains(err.Error(), "prompt") || !strings.Contains(strings.ToLower(err.Error()), "matcher") {
t.Errorf("error should mention prompt+matcher; got %q", err)
}
}
func TestValidate_RejectsInvalidRegex(t *testing.T) {
h := baseValidCommandHook()
h.Matcher = "[unclosed"
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for malformed regex")
}
if !strings.Contains(strings.ToLower(err.Error()), "matcher") {
t.Errorf("error should mention matcher; got %q", err)
}
}
func TestValidate_RejectsMalformedCEL(t *testing.T) {
h := baseValidCommandHook()
h.IfExpr = `tool_name ===== "bad"` // CEL syntax error
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for malformed CEL")
}
if !strings.Contains(strings.ToLower(err.Error()), "if_expr") && !strings.Contains(strings.ToLower(err.Error()), "cel") {
t.Errorf("error should mention if_expr/cel; got %q", err)
}
}
// Command on Standard + tenant scope is blocked via edition gate. Plan C2 drop:
// command handler is Lite-only (multi-tenant host can't run arbitrary shell).
func TestValidate_RejectsCommandOnStandardTenant(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeTenant
h.TenantID = uuid.New()
err := h.Validate(edition.Standard, false)
if err == nil {
t.Fatal("expected error for command on Standard+tenant")
}
if !strings.Contains(strings.ToLower(err.Error()), "command") {
t.Errorf("error should mention command; got %q", err)
}
}
func TestValidate_RejectsCommandOnStandardAgent(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeAgent
h.TenantID = uuid.New()
agentID := uuid.New()
h.AgentID = &agentID
err := h.Validate(edition.Standard, false)
if err == nil {
t.Fatal("expected error for command on Standard+agent")
}
}
func TestValidate_AcceptsCommandOnLiteAnyScope(t *testing.T) {
for _, scope := range []hooks.Scope{hooks.ScopeGlobal, hooks.ScopeTenant, hooks.ScopeAgent} {
h := baseValidCommandHook()
h.Scope = scope
if scope != hooks.ScopeGlobal {
h.TenantID = uuid.New()
}
if scope == hooks.ScopeAgent {
agentID := uuid.New()
h.AgentID = &agentID
}
if err := h.Validate(edition.Lite, false); err != nil {
t.Errorf("Lite+command+%s should be valid; got %v", scope, err)
}
}
}
// TimeoutMS = 0 is replaced with a sane default so callers don't accidentally
// ship zero-timeout hooks that return immediately.
func TestValidate_AppliesTimeoutDefault(t *testing.T) {
h := baseValidCommandHook()
h.TimeoutMS = 0
if err := h.Validate(edition.Lite, false); err != nil {
t.Fatalf("Validate: %v", err)
}
if h.TimeoutMS != hooks.DefaultTimeoutMS {
t.Errorf("TimeoutMS default = %d, want %d", h.TimeoutMS, hooks.DefaultTimeoutMS)
}
}
// OnTimeout defaults to "block" for blocking events (fail-closed) and to
// "allow" for non-blocking ones.
func TestValidate_AppliesOnTimeoutDefault(t *testing.T) {
h := baseValidCommandHook()
h.OnTimeout = ""
h.Event = hooks.EventPreToolUse // blocking
if err := h.Validate(edition.Lite, false); err != nil {
t.Fatalf("Validate: %v", err)
}
if h.OnTimeout != hooks.DecisionBlock {
t.Errorf("OnTimeout default for blocking event = %q, want %q", h.OnTimeout, hooks.DecisionBlock)
}
h2 := baseValidCommandHook()
h2.OnTimeout = ""
h2.Event = hooks.EventPostToolUse // non-blocking
if err := h2.Validate(edition.Lite, false); err != nil {
t.Fatalf("Validate: %v", err)
}
if h2.OnTimeout != hooks.DecisionAllow {
t.Errorf("OnTimeout default for non-blocking event = %q, want %q", h2.OnTimeout, hooks.DecisionAllow)
}
}
// Version defaults to 1 when zero — initial Create path.
func TestValidate_AppliesVersionDefault(t *testing.T) {
h := baseValidCommandHook()
h.Version = 0
if err := h.Validate(edition.Lite, false); err != nil {
t.Fatalf("Validate: %v", err)
}
if h.Version != 1 {
t.Errorf("Version default = %d, want 1", h.Version)
}
}
// Source defaults to "ui" when empty — distinguishes UI writes from agent-seeded
// hooks (M1 isolation in plan).
func TestValidate_AppliesSourceDefault(t *testing.T) {
h := baseValidCommandHook()
h.Source = ""
if err := h.Validate(edition.Lite, false); err != nil {
t.Fatalf("Validate: %v", err)
}
if h.Source != "ui" {
t.Errorf("Source default = %q, want %q", h.Source, "ui")
}
}
func TestValidate_RejectsUnknownEvent(t *testing.T) {
h := baseValidCommandHook()
h.Event = hooks.HookEvent("webhook_magic")
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for unknown event")
}
}
func TestValidate_RejectsGlobalScopeWithNonSentinelTenant(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeGlobal
h.TenantID = uuid.New() // must be sentinel for global
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for global scope with non-sentinel tenant")
}
}
func TestValidate_RejectsAgentScopeWithoutAgentID(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeAgent
h.TenantID = uuid.New()
h.AgentID = nil
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for agent scope without agent_id")
}
}
func TestValidate_RejectsNegativeTimeout(t *testing.T) {
h := baseValidCommandHook()
h.TimeoutMS = -1
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for negative timeout")
}
}
func TestValidate_CapsTimeoutToMax(t *testing.T) {
h := baseValidCommandHook()
h.TimeoutMS = 60 * 1000 // 60s — exceeds chain wall-time budget
err := h.Validate(edition.Lite, false)
if err == nil {
t.Fatal("expected error for timeout > MaxTimeoutMS")
}
}
// ─── Script handler validation (Phase 03) ────────────────────────────────────
func baseValidScriptHook() hooks.HookConfig {
h := baseValidCommandHook()
h.HandlerType = hooks.HandlerScript
h.Config = map[string]any{"source": `function handle(e){return {decision:"allow"}}`}
return h
}
func TestValidate_AcceptsValidScriptHook(t *testing.T) {
h := baseValidScriptHook()
if err := h.Validate(edition.Standard, false); err != nil {
t.Fatalf("expected nil error; got %v", err)
}
}
func TestValidate_RejectsEmptyScriptSource(t *testing.T) {
h := baseValidScriptHook()
h.Config = map[string]any{"source": ""}
err := h.Validate(edition.Standard, false)
if err == nil || !strings.Contains(err.Error(), "non-empty") {
t.Fatalf("expected non-empty source error; got %v", err)
}
}
func TestValidate_RejectsOversizedScriptSource(t *testing.T) {
h := baseValidScriptHook()
big := strings.Repeat("// pad\n", 6000) // >32 KiB
h.Config = map[string]any{"source": big}
err := h.Validate(edition.Standard, false)
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("expected exceeds-bytes error; got %v", err)
}
}
func TestValidate_RejectsScriptCompileError(t *testing.T) {
h := baseValidScriptHook()
// Missing closing brace → parser error with line:col.
h.Config = map[string]any{"source": `function handle(e) { return {`}
err := h.Validate(edition.Standard, false)
if err == nil || !strings.Contains(err.Error(), "compile error") {
t.Fatalf("expected compile error; got %v", err)
}
}
func TestValidate_RejectsOnTimeoutAsk(t *testing.T) {
h := baseValidScriptHook()
h.OnTimeout = hooks.DecisionAsk
err := h.Validate(edition.Standard, false)
if err == nil || !strings.Contains(err.Error(), "on_timeout") {
t.Fatalf("expected on_timeout rejection; got %v", err)
}
}
func TestValidate_RejectsOnTimeoutDefer(t *testing.T) {
h := baseValidScriptHook()
h.OnTimeout = hooks.DecisionDefer
err := h.Validate(edition.Standard, false)
if err == nil || !strings.Contains(err.Error(), "on_timeout") {
t.Fatalf("expected on_timeout rejection; got %v", err)
}
}
// TestValidate_MasterScopeAllowsSystemTenantHooks verifies that master/owner
// callers can create ScopeTenant and ScopeAgent hooks whose TenantID equals
// MasterTenantID (the real system tenant row). Before this fix, validateScope
// rejected such hooks because SentinelTenantID == MasterTenantID.
func TestValidate_MasterScopeAllowsSystemTenantHooks(t *testing.T) {
systemTenantID := hooks.SentinelTenantID // same UUID as MasterTenantID
t.Run("ScopeTenant with MasterTenantID as master", func(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeTenant
h.TenantID = systemTenantID
if err := h.Validate(edition.Lite, true); err != nil {
t.Errorf("master scope should accept ScopeTenant+MasterTenantID; got %v", err)
}
})
t.Run("ScopeAgent with MasterTenantID as master", func(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeAgent
h.TenantID = systemTenantID
agentID := uuid.New()
h.AgentID = &agentID
if err := h.Validate(edition.Lite, true); err != nil {
t.Errorf("master scope should accept ScopeAgent+MasterTenantID; got %v", err)
}
})
t.Run("ScopeTenant with MasterTenantID as non-master is rejected", func(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeTenant
h.TenantID = systemTenantID
if err := h.Validate(edition.Lite, false); err == nil {
t.Error("non-master scope should reject ScopeTenant+MasterTenantID")
}
})
t.Run("ScopeAgent with uuid.Nil is always rejected", func(t *testing.T) {
h := baseValidCommandHook()
h.Scope = hooks.ScopeAgent
h.TenantID = uuid.Nil
agentID := uuid.New()
h.AgentID = &agentID
if err := h.Validate(edition.Lite, true); err == nil {
t.Error("uuid.Nil tenant_id should always be rejected for agent scope")
}
})
}