Files

266 lines
8.8 KiB
Go

package handlers
import (
"context"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// newTestHandler constructs a handler with small caps tuned for unit tests.
func newTestHandler() *ScriptHandler {
return NewScriptHandler(4, 2, 32)
}
// mkCfg builds a minimal HookConfig carrying only what the script handler reads.
func mkCfg(source string) hooks.HookConfig {
return hooks.HookConfig{
ID: uuid.New(),
TenantID: uuid.New(),
Event: hooks.EventUserPromptSubmit,
HandlerType: hooks.HandlerScript,
Scope: hooks.ScopeTenant,
Config: map[string]any{"source": source},
Version: 1,
Enabled: true,
}
}
// mkEvent builds a minimal Event with non-nil maps so the frozen binding has
// something to iterate.
func mkEvent() hooks.Event {
return hooks.Event{
EventID: "evt-1",
SessionID: "sess-1",
TenantID: uuid.New(),
AgentID: uuid.New(),
ToolName: "test_tool",
ToolInput: map[string]any{"path": "/tmp/x", "n": 3},
RawInput: "hello",
HookEvent: hooks.EventUserPromptSubmit,
}
}
// runWithResult executes and returns the decision plus the ScriptResult the
// handler populated via the ctx-carried pointer.
func runWithResult(t *testing.T, h *ScriptHandler, cfg hooks.HookConfig, ev hooks.Event, timeout time.Duration) (hooks.Decision, error, *hooks.ScriptResult) {
t.Helper()
res := &hooks.ScriptResult{}
ctx, cancel := context.WithTimeout(hooks.WithScriptResult(context.Background(), res), timeout)
defer cancel()
d, err := h.Execute(ctx, cfg, ev)
return d, err, res
}
// TestHappyPathAllow verifies a script returning {decision:"allow",reason:"ok"}
// produces DecisionAllow and the reason flows to ScriptResult.
func TestHappyPathAllow(t *testing.T) {
src := `function handle(event) { return {decision: "allow", reason: "ok"}; }`
h := newTestHandler()
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if dec != hooks.DecisionAllow {
t.Fatalf("decision: got %v, want allow", dec)
}
if res.Reason != "ok" {
t.Fatalf("reason: got %q, want %q", res.Reason, "ok")
}
}
func TestAllowReturnsAdditionalContext(t *testing.T) {
src := `function handle(event) { return {decision: "allow", additionalContext: "Use the required workflow."}; }`
h := newTestHandler()
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if err != nil || dec != hooks.DecisionAllow {
t.Fatalf("decision=%v err=%v", dec, err)
}
if res.AdditionalContext != "Use the required workflow." {
t.Fatalf("additional context=%q", res.AdditionalContext)
}
}
// TestHappyPathBlock verifies block + reason round-trips.
func TestHappyPathBlock(t *testing.T) {
src := `function handle(event) { return {decision: "block", reason: "nope"}; }`
h := newTestHandler()
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if dec != hooks.DecisionBlock {
t.Fatalf("decision: got %v, want block", dec)
}
if res.Reason != "nope" {
t.Fatalf("reason: got %q", res.Reason)
}
}
// TestMissingHandleFunction yields DecisionError when the script omits `handle`.
func TestMissingHandleFunction(t *testing.T) {
src := `var x = 1;`
h := newTestHandler()
dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if dec != hooks.DecisionError {
t.Fatalf("decision: got %v, want error", dec)
}
if err == nil || !strings.Contains(err.Error(), "handle") {
t.Fatalf("err: %v (want mentions 'handle')", err)
}
}
// TestNonObjectReturn yields DecisionError.
func TestNonObjectReturn(t *testing.T) {
src := `function handle(event) { return 42; }`
h := newTestHandler()
dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if dec != hooks.DecisionError {
t.Fatalf("decision: got %v, want error (err=%v)", dec, err)
}
}
// TestInvalidDecisionString yields DecisionError with message containing the bad string.
func TestInvalidDecisionString(t *testing.T) {
src := `function handle(event) { return {decision: "maybe"}; }`
h := newTestHandler()
dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if dec != hooks.DecisionError {
t.Fatalf("decision: got %v", dec)
}
if err == nil || !strings.Contains(err.Error(), "maybe") {
t.Fatalf("err should mention the invalid value, got %v", err)
}
}
// TestTimeoutInfiniteLoop verifies ctx cancellation interrupts a runaway script.
func TestTimeoutInfiniteLoop(t *testing.T) {
src := `function handle(event) { while(true){} }`
h := newTestHandler()
dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 100*time.Millisecond)
if dec != hooks.DecisionTimeout {
t.Fatalf("decision: got %v, want timeout (err=%v)", dec, err)
}
}
// TestPanicInsideHandle surfaces as DecisionError with sanitized message
// (no newline, no " at " frame).
func TestPanicInsideHandle(t *testing.T) {
src := `function handle(event) { throw new Error("boom"); }`
h := newTestHandler()
dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if dec != hooks.DecisionError {
t.Fatalf("decision: got %v", dec)
}
if err == nil {
t.Fatal("err: nil, want non-nil")
}
if strings.Contains(err.Error(), "\n") {
t.Fatalf("err leaks multi-line stack trace: %q", err.Error())
}
if strings.Contains(err.Error(), " at ") {
t.Fatalf("err leaks stack frame text: %q", err.Error())
}
}
// TestAskDecisionBlocksInWave1 verifies ask/defer map to block.
func TestAskDecisionBlocksInWave1(t *testing.T) {
src := `function handle(event) { return {decision: "ask", reason: "pls"}; }`
h := newTestHandler()
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if dec != hooks.DecisionBlock {
t.Fatalf("decision: got %v, want block (ask→block in Wave 1)", dec)
}
if res.Reason != "pls" {
t.Fatalf("reason lost: %q", res.Reason)
}
}
// TestDeferDecisionBlocksInWave1 mirror of the ask case.
func TestDeferDecisionBlocksInWave1(t *testing.T) {
src := `function handle(event) { return {decision: "defer"}; }`
h := newTestHandler()
dec, _, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if dec != hooks.DecisionBlock {
t.Fatalf("decision: got %v, want block (defer→block in Wave 1)", dec)
}
}
// TestSourceOverSizeCap rejects scripts larger than MaxScriptSourceBytes.
func TestSourceOverSizeCap(t *testing.T) {
big := strings.Repeat("// padding ", MaxScriptSourceBytes/10+10) // >32KiB
src := `function handle(event){return {decision:"allow"}}` + "\n" + big
h := newTestHandler()
dec, err, _ := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if dec != hooks.DecisionError {
t.Fatalf("decision: got %v, want error", dec)
}
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("err: %v", err)
}
}
// TestStdoutCapTruncates verifies the 4KiB ceiling cuts off runaway logging
// with a single truncation marker.
func TestStdoutCapTruncates(t *testing.T) {
src := `function handle(event) {
for (var i = 0; i < 5000; i++) { console.log("xxxxxxxxxx"); }
return {decision: "allow"};
}`
h := newTestHandler()
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 2*time.Second)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if dec != hooks.DecisionAllow {
t.Fatalf("decision: got %v", dec)
}
if len(res.Stdout) > MaxStdoutBytes+64 { // +64 slack for marker tail
t.Fatalf("stdout exceeded cap: %d bytes", len(res.Stdout))
}
if !strings.Contains(res.Stdout, "truncated") {
end := min(200, len(res.Stdout))
t.Fatalf("truncation marker missing: %q", res.Stdout[:end])
}
}
// TestUpdatedInputCaptured verifies updatedInput flows back via ScriptResult.
func TestUpdatedInputCaptured(t *testing.T) {
src := `function handle(event) {
return {decision: "allow", updatedInput: {path: "/redacted"}};
}`
h := newTestHandler()
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if dec != hooks.DecisionAllow {
t.Fatalf("decision: got %v", dec)
}
got, _ := res.UpdatedInput["path"].(string)
if got != "/redacted" {
t.Fatalf("updatedInput.path: got %q, want /redacted", got)
}
}
// TestInvalidateHookDoesNotPanic is a smoke test for the Phase-03 wire-up point.
func TestInvalidateHookDoesNotPanic(t *testing.T) {
h := newTestHandler()
cfg := mkCfg(`function handle(event){return {decision:"allow"}}`)
if _, err := h.Execute(context.Background(), cfg, mkEvent()); err != nil {
t.Fatalf("warmup err: %v", err)
}
h.InvalidateHook(cfg.ID)
// Re-execute to ensure cache repopulates cleanly.
if _, err := h.Execute(context.Background(), cfg, mkEvent()); err != nil {
t.Fatalf("post-invalidate err: %v", err)
}
}