mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
266 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|