Skip to content

Commit 3218989

Browse files
authored
Merge pull request #22 from evolution-foundation/feat/EVO-1738-custom-tools-test-payload-endpoint
feat(EVO-1738): stateless POST /custom-tools/test (test-before-save)
2 parents d66fcc5 + 46c8fb6 commit 3218989

5 files changed

Lines changed: 362 additions & 8 deletions

File tree

pkg/custom_tool/handler/custom_tool_handler.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type CustomToolHandler interface {
2525
Update(c *gin.Context)
2626
Delete(c *gin.Context)
2727
Test(c *gin.Context)
28+
TestPayload(c *gin.Context)
2829
}
2930

3031
// customToolHandler implements the CustomToolHandler interface.
@@ -75,6 +76,14 @@ func (h *customToolHandler) RegisterRoutesMiddleware(router gin.IRouter) {
7576
permissionMiddleware.RequirePermission("ai_custom_tools", "delete"),
7677
h.Delete)
7778

79+
// EVO-1738: stateless test-before-save (validates the payload typed in the wizard).
80+
// Gated on "create", NOT "read": unlike GET /:id/test — which only replays a tool
81+
// someone with create rights already authored — this takes method/endpoint/headers
82+
// straight from the request body, so a read-only user would otherwise gain an
83+
// arbitrary server-side HTTP fetcher (our egress, our IP, our network position).
84+
customTools.POST("/test",
85+
permissionMiddleware.RequirePermission("ai_custom_tools", "create"),
86+
h.TestPayload)
7887
// Test permissions
7988
customTools.GET("/:id/test",
8089
permissionMiddleware.RequirePermission("ai_custom_tools", "read"),
@@ -323,3 +332,21 @@ func (h *customToolHandler) Test(c *gin.Context) {
323332

324333
response.SuccessResponse(c, customTool, "Custom tool test completed successfully", http.StatusOK)
325334
}
335+
336+
// TestPayload tests an UNSAVED tool payload (test-before-save). EVO-1738.
337+
func (h *customToolHandler) TestPayload(c *gin.Context) {
338+
var req model.CustomToolTestPayloadRequest
339+
if err := c.ShouldBindJSON(&req); err != nil {
340+
response.ValidationErrorResponse(c, err)
341+
return
342+
}
343+
344+
testResult, err := h.customToolService.TestPayload(c.Request.Context(), req)
345+
if err != nil {
346+
code, message, httpCode := errors.HandleError(err)
347+
response.ErrorResponse(c, code, message, nil, httpCode)
348+
return
349+
}
350+
351+
response.SuccessResponse(c, gin.H{"test_result": testResult}, "Custom tool test completed successfully", http.StatusOK)
352+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package handler
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
apiErrors "evo-ai-core-service/internal/httpclient/errors"
8+
"evo-ai-core-service/pkg/custom_tool/model"
9+
"evo-ai-core-service/pkg/custom_tool/service"
10+
"net/http"
11+
"net/http/httptest"
12+
"testing"
13+
14+
"github.com/gin-gonic/gin"
15+
)
16+
17+
// stubService implements service.CustomToolService; only TestPayload is exercised.
18+
type stubService struct {
19+
service.CustomToolService
20+
gotReq model.CustomToolTestPayloadRequest
21+
result *model.TestResult
22+
err error
23+
}
24+
25+
func (s *stubService) TestPayload(_ context.Context, req model.CustomToolTestPayloadRequest) (*model.TestResult, error) {
26+
s.gotReq = req
27+
return s.result, s.err
28+
}
29+
30+
func postTestPayload(t *testing.T, svc service.CustomToolService, body string) *httptest.ResponseRecorder {
31+
t.Helper()
32+
gin.SetMode(gin.TestMode)
33+
rec := httptest.NewRecorder()
34+
c, _ := gin.CreateTestContext(rec)
35+
c.Request = httptest.NewRequest(http.MethodPost, "/custom-tools/test", bytes.NewBufferString(body))
36+
c.Request.Header.Set("Content-Type", "application/json")
37+
38+
NewCustomToolHandler(svc).TestPayload(c)
39+
return rec
40+
}
41+
42+
// R1 review (EVO-1738): the handler package had no tests at all, so the new
43+
// test-before-save endpoint's binding and response envelope were unverified.
44+
func TestTestPayloadHandler_ForwardsFullRequestAndWrapsResult(t *testing.T) {
45+
svc := &stubService{result: &model.TestResult{Success: true, StatusCode: 200, Body: `{"ok":true}`}}
46+
47+
rec := postTestPayload(t, svc, `{
48+
"method": "GET",
49+
"endpoint": "https://api.example.com/users/{user_id}",
50+
"headers": {"X-Token": "abc"},
51+
"path_params": {"user_id": "42"},
52+
"query_params": {"limit": 10},
53+
"body_params": {"ignored": true}
54+
}`)
55+
56+
if rec.Code != http.StatusOK {
57+
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
58+
}
59+
60+
// path_params/query_params must survive the bind — dropping them is what made
61+
// the wizard report "Request OK" for a request missing the user's config.
62+
if svc.gotReq.PathParams["user_id"] != "42" {
63+
t.Fatalf("path_params not bound: %#v", svc.gotReq.PathParams)
64+
}
65+
if svc.gotReq.QueryParams["limit"] != float64(10) {
66+
t.Fatalf("query_params not bound: %#v", svc.gotReq.QueryParams)
67+
}
68+
if svc.gotReq.Headers["X-Token"] != "abc" {
69+
t.Fatalf("headers not bound: %#v", svc.gotReq.Headers)
70+
}
71+
72+
var envelope struct {
73+
Success bool `json:"success"`
74+
Data struct {
75+
TestResult model.TestResult `json:"test_result"`
76+
} `json:"data"`
77+
}
78+
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
79+
t.Fatalf("unmarshal response: %v", err)
80+
}
81+
if !envelope.Success || !envelope.Data.TestResult.Success || envelope.Data.TestResult.StatusCode != 200 {
82+
t.Fatalf("unexpected envelope: %s", rec.Body.String())
83+
}
84+
}
85+
86+
func TestTestPayloadHandler_RejectsMissingRequiredFields(t *testing.T) {
87+
rec := postTestPayload(t, &stubService{}, `{"endpoint": "https://api.example.com"}`)
88+
if rec.Code != http.StatusBadRequest {
89+
t.Fatalf("want 400 when method is missing, got %d: %s", rec.Code, rec.Body.String())
90+
}
91+
}
92+
93+
// An unsupported method is caller input: it must surface as 400 with the real
94+
// reason, not a generic 500 the UI cannot explain.
95+
func TestTestPayloadHandler_MapsServiceApiErrorStatus(t *testing.T) {
96+
svc := &stubService{err: apiErrors.New(apiErrors.InvalidInput, "unsupported method: TRACE", http.StatusBadRequest)}
97+
98+
rec := postTestPayload(t, svc, `{"method": "TRACE", "endpoint": "https://api.example.com"}`)
99+
if rec.Code != http.StatusBadRequest {
100+
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
101+
}
102+
103+
var envelope struct {
104+
Error struct {
105+
Code string `json:"code"`
106+
Message string `json:"message"`
107+
} `json:"error"`
108+
}
109+
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
110+
t.Fatalf("unmarshal response: %v", err)
111+
}
112+
// The frontend reads error.message — assert the reason actually lands there.
113+
if envelope.Error.Message != "unsupported method: TRACE" {
114+
t.Fatalf("reason not surfaced in error.message: %s", rec.Body.String())
115+
}
116+
}

pkg/custom_tool/model/custom_tool.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ type CustomToolTestResponse struct {
111111
TestResult *TestResult `json:"test_result"`
112112
}
113113

114+
// CustomToolTestPayloadRequest is an UNSAVED tool config to run once (EVO-1738,
115+
// test-before-save). Mirrors the request-shaping fields of CustomToolBase so the
116+
// wizard's test hits exactly the URL the saved tool would.
117+
type CustomToolTestPayloadRequest struct {
118+
Method string `json:"method" binding:"required"`
119+
Endpoint string `json:"endpoint" binding:"required"`
120+
Headers map[string]string `json:"headers"`
121+
PathParams map[string]string `json:"path_params"`
122+
QueryParams map[string]interface{} `json:"query_params"`
123+
BodyParams map[string]interface{} `json:"body_params"`
124+
}
125+
114126
type CustomToolListResponse struct {
115127
Items []CustomToolResponse `json:"-"`
116128
Page int `json:"-"`

pkg/custom_tool/service/custom_tool_service.go

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
8+
apiErrors "evo-ai-core-service/internal/httpclient/errors"
79
errorsPostgres "evo-ai-core-service/internal/infra/postgres"
810
"evo-ai-core-service/internal/utils/stringutils"
911
model "evo-ai-core-service/pkg/custom_tool/model"
1012
repository "evo-ai-core-service/pkg/custom_tool/repository"
11-
"errors"
1213
"fmt"
1314
"io"
1415
"net"
@@ -165,9 +166,59 @@ var testHTTPClient = &http.Client{
165166
},
166167
}
167168

169+
// toQueryValue renders a query-param value as a string. Scalars go verbatim;
170+
// objects/arrays are JSON-encoded so we never emit Go's map[k:v] syntax.
171+
func toQueryValue(v interface{}) string {
172+
switch t := v.(type) {
173+
case nil:
174+
return ""
175+
case string:
176+
return t
177+
case map[string]interface{}, []interface{}:
178+
if buf, err := json.Marshal(t); err == nil {
179+
return string(buf)
180+
}
181+
return fmt.Sprintf("%v", t)
182+
default:
183+
return fmt.Sprintf("%v", t)
184+
}
185+
}
186+
187+
// resolveToolURL applies the tool's path placeholders and query params to the raw
188+
// endpoint, yielding the URL the tool actually calls. Without this the test ran a
189+
// bare endpoint — `/users/{user_id}` was requested literally and query params were
190+
// dropped, so the result did not reflect the configured tool.
191+
//
192+
// Safe by construction: the result is fed to runToolTest, which runs
193+
// validateEndpoint on the FINAL url — so a placeholder that expands into an
194+
// internal host is still caught by the SSRF gate.
195+
func resolveToolURL(endpoint string, pathParams map[string]string, queryParams map[string]interface{}) string {
196+
resolved := endpoint
197+
for k, v := range pathParams {
198+
resolved = strings.ReplaceAll(resolved, "{"+k+"}", url.PathEscape(v))
199+
}
200+
201+
if len(queryParams) == 0 {
202+
return resolved
203+
}
204+
205+
u, err := url.Parse(strings.TrimSpace(resolved))
206+
if err != nil {
207+
// Let validateEndpoint produce the user-facing parse error.
208+
return resolved
209+
}
210+
q := u.Query()
211+
for k, v := range queryParams {
212+
q.Set(k, toQueryValue(v))
213+
}
214+
u.RawQuery = q.Encode()
215+
return u.String()
216+
}
217+
168218
// runToolTest issues the HTTP request described by the tool and returns a
169219
// TestResult reflecting what actually happened: real status code, response
170220
// time, headers, body. Success = HTTP 2xx (not body parseability).
221+
// `endpoint` must already be resolved (see resolveToolURL).
171222
func runToolTest(
172223
ctx context.Context,
173224
method, endpoint string,
@@ -279,6 +330,9 @@ type CustomToolService interface {
279330
Delete(ctx context.Context, id uuid.UUID) (bool, error)
280331
ConvertToHTTPTool(tool model.CustomToolResponse) map[string]interface{}
281332
Test(ctx context.Context, id uuid.UUID) (*model.CustomToolTestResponse, error)
333+
// EVO-1738: stateless test of an UNSAVED tool payload (test-before-save in the
334+
// wizard). Same SSRF-hardened runToolTest as Test, without requiring a saved tool.
335+
TestPayload(ctx context.Context, req model.CustomToolTestPayloadRequest) (*model.TestResult, error)
282336
}
283337

284338
type customToolService struct {
@@ -420,21 +474,52 @@ func (s *customToolService) Test(ctx context.Context, id uuid.UUID) (*model.Cust
420474
}
421475

422476
response := customTool.ToResponse()
423-
method := strings.ToUpper(customTool.Method)
424477

425-
switch method {
426-
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodHead, http.MethodOptions:
427-
// supported
428-
default:
429-
return nil, fmt.Errorf("unsupported method: %s", customTool.Method)
478+
method, err := normalizeToolMethod(customTool.Method)
479+
if err != nil {
480+
return nil, err
430481
}
431482

432483
headers := stringutils.JSONToStringMap(customTool.Headers)
433484
bodyParams := stringutils.JSONToInterfaceMap(customTool.BodyParams)
434-
testResult := runToolTest(ctx, method, customTool.Endpoint, headers, bodyParams)
485+
endpoint := resolveToolURL(
486+
customTool.Endpoint,
487+
stringutils.JSONToStringMap(customTool.PathParams),
488+
stringutils.JSONToInterfaceMap(customTool.QueryParams),
489+
)
490+
testResult := runToolTest(ctx, method, endpoint, headers, bodyParams)
435491

436492
return &model.CustomToolTestResponse{
437493
Tool: response,
438494
TestResult: testResult,
439495
}, nil
440496
}
497+
498+
// normalizeToolMethod upper-cases the method and enforces the allowlist, shared by
499+
// the saved-tool test and the stateless payload test so the two cannot drift.
500+
// Returns a BadRequest ApiError — an unsupported method is caller input, not a 500.
501+
func normalizeToolMethod(method string) (string, error) {
502+
upper := strings.ToUpper(strings.TrimSpace(method))
503+
switch upper {
504+
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodHead, http.MethodOptions:
505+
return upper, nil
506+
default:
507+
return "", apiErrors.New(
508+
apiErrors.InvalidInput,
509+
fmt.Sprintf("unsupported method: %s", method),
510+
http.StatusBadRequest,
511+
)
512+
}
513+
}
514+
515+
// TestPayload runs the SSRF-hardened tool request against an UNSAVED payload —
516+
// powers the wizard's "test before save" (EVO-1738).
517+
func (s *customToolService) TestPayload(ctx context.Context, req model.CustomToolTestPayloadRequest) (*model.TestResult, error) {
518+
method, err := normalizeToolMethod(req.Method)
519+
if err != nil {
520+
return nil, err
521+
}
522+
523+
endpoint := resolveToolURL(req.Endpoint, req.PathParams, req.QueryParams)
524+
return runToolTest(ctx, method, endpoint, req.Headers, req.BodyParams), nil
525+
}

0 commit comments

Comments
 (0)