package agent import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "code.littlelan.cn/CarrotAssistant/backend/internal/skill" ) // TestRenderTemplate covers the {{param}} substitution used to build tool // requests. It must substitute known args, drop unfilled placeholders, and // leave templates with no markers untouched. func TestRenderTemplate(t *testing.T) { args := map[string]any{"keyword": "golang", "limit": 10} cases := []struct{ in, want string }{ {"https://x.com/search?q={{keyword}}", "https://x.com/search?q=golang"}, {"{{limit}}", "10"}, {"no markers here", "no markers here"}, {"q={{keyword}}&p={{missing}}", "q=golang&p="}, {"{{a}}{{b}}", ""}, // both unfilled -> empty } for _, c := range cases { got := renderTemplate(c.in, args) if got != c.want { t.Errorf("renderTemplate(%q) = %q, want %q", c.in, got, c.want) } } } // TestExecuteReadOnly verifies the default readonly policy blocks mutating // methods that are neither allow-listed nor confirmation-listed. func TestExecuteReadOnlyBlocksMutating(t *testing.T) { tool := skill.Tool{ Name: "delete_post", Endpoint: skill.Endpoint{Method: "DELETE", URL: "https://example.com/x"}, } out, err := Execute(context.Background(), tool, skill.Permissions{DefaultMode: "readonly"}, nil) if err != nil { t.Fatalf("execute: %v", err) } if !out.Denied { t.Fatalf("expected Denied, got %+v", out) } } // TestExecuteConfirmation verifies a mutating tool on the confirmation list // pauses execution rather than firing the request. func TestExecuteConfirmation(t *testing.T) { called := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true w.WriteHeader(200) })) defer srv.Close() tool := skill.Tool{ Name: "delete_post", Endpoint: skill.Endpoint{Method: "DELETE", URL: srv.URL + "/x"}, } perms := skill.Permissions{ DefaultMode: "readonly", RequireConfirmation: []string{"delete_post"}, } out, err := Execute(context.Background(), tool, perms, map[string]any{"id": 1}) if err != nil { t.Fatalf("execute: %v", err) } if !out.NeedsConfirmation { t.Fatalf("expected NeedsConfirmation, got %+v", out) } if called { t.Fatalf("server was called during a confirmation-pending call") } } // TestExecuteAllowWrite verifies a mutating tool on the allow_write list runs // immediately against the target site. func TestExecuteAllowWrite(t *testing.T) { gotMethod := "" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method w.WriteHeader(200) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() tool := skill.Tool{ Name: "create_post", Endpoint: skill.Endpoint{ Method: "POST", URL: srv.URL + "/posts", Body: `{"title":"{{title}}"}`, }, } perms := skill.Permissions{ DefaultMode: "readonly", AllowWrite: []string{"create_post"}, } out, err := Execute(context.Background(), tool, perms, map[string]any{"title": "hi"}) if err != nil { t.Fatalf("execute: %v", err) } if out.NeedsConfirmation || out.Denied { t.Fatalf("expected direct execution, got %+v", out) } if gotMethod != "POST" { t.Fatalf("expected POST, got %s", gotMethod) } var resp map[string]any if err := json.Unmarshal([]byte(out.Result), &resp); err != nil || resp["ok"] != true { t.Fatalf("unexpected result: %s", out.Result) } } // TestExecuteGET verifies a read-only GET runs without any permission entries. func TestExecuteGET(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("q") != "golang" { t.Errorf("query q = %q, want golang", r.URL.Query().Get("q")) } _, _ = w.Write([]byte(`["post1","post2"]`)) })) defer srv.Close() tool := skill.Tool{ Name: "search", Endpoint: skill.Endpoint{ Method: "GET", URL: srv.URL + "/search", Query: map[string]string{"q": "{{q}}"}, }, } out, err := Execute(context.Background(), tool, skill.Permissions{DefaultMode: "readonly"}, map[string]any{"q": "golang"}) if err != nil { t.Fatalf("execute: %v", err) } if out.Result != `["post1","post2"]` { t.Fatalf("result = %s", out.Result) } }