Files
ragflow/internal/handler/agent_webhook_test.go
T

1361 lines
46 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/iotest"
"time"
"github.com/gin-gonic/gin"
"ragflow/internal/agent/canvas"
dslpkg "ragflow/internal/agent/dsl"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/permission"
"ragflow/internal/service"
)
// fakeCanvasLoader is a stand-in canvasLoader for webhook tests. It
// returns the canned canvas/run events configured at construction time
// without touching the database. Mirrors the pattern used by
// waitFakeAgentService at internal/handler/agent_wait_for_user_test.go.
type fakeCanvasLoader struct {
canvas *entity.UserCanvas
err error
runErr error
events []canvas.RunEvent
waitForCancellation bool
loadUserID string
runUserID string
runCanvasID string
runContext context.Context
runPayload map[string]any
runStarted chan string
runCalls atomic.Int32
}
type fakeAgentWebhookCredentialManager struct {
credential entity.AgentWebhookCredential
token string
resolveErr error
issueToken string
issueErr error
resolveID string
resolveToken string
revokedID string
revokedUser string
}
func (f *fakeAgentWebhookCredentialManager) Issue(_ context.Context, _ permission.Subject, _ *entity.UserCanvas) (string, error) {
return f.issueToken, f.issueErr
}
func (f *fakeAgentWebhookCredentialManager) Resolve(_ context.Context, canvasID, token string) (*entity.AgentWebhookCredential, error) {
f.resolveID = canvasID
f.resolveToken = token
if f.resolveErr != nil || token != f.token {
return nil, service.ErrInvalidAgentWebhookCredential
}
credential := f.credential
credential.CanvasID = canvasID
return &credential, nil
}
func (f *fakeAgentWebhookCredentialManager) Revoke(_ context.Context, canvasID, userID string) error {
f.revokedID = canvasID
f.revokedUser = userID
return nil
}
func (f *fakeCanvasLoader) LoadCanvasByID(_ context.Context, userID, _ string) (*entity.UserCanvas, error) {
f.loadUserID = userID
if f.err != nil {
return nil, f.err
}
return f.canvas, nil
}
func (f *fakeCanvasLoader) RunAgentWithWebhook(ctx context.Context, userID, canvasID string, payload map[string]any) (<-chan canvas.RunEvent, error) {
f.runCalls.Add(1)
f.runUserID = userID
f.runCanvasID = canvasID
f.runContext = ctx
f.runPayload = payload
if f.runStarted != nil {
f.runStarted <- userID
}
if f.runErr != nil {
return nil, f.runErr
}
if f.waitForCancellation {
out := make(chan canvas.RunEvent)
go func() {
<-ctx.Done()
close(out)
}()
return out, nil
}
out := make(chan canvas.RunEvent, len(f.events))
for _, e := range f.events {
out <- e
}
close(out)
return out, nil
}
// makeWebhookCanvas builds a minimal canvas with a Begin component
// whose params.mode == "Webhook" and the supplied params map. The
// `params` argument becomes webhook_cfg inside the handler.
//
// As of PR #14890 the webhook requires a security block — empty
// configs are rejected. Tests that don't care about auth inject
// an explicit anonymous-opt-in block (auth_type=none +
// allow_anonymous=true) so the handler proceeds to the
// schema/content-type checks under test.
func makeWebhookCanvas(id, userID, mode string, params map[string]any) *entity.UserCanvas {
dsl := map[string]any{
"components": map[string]any{
"begin": map[string]any{
"obj": map[string]any{
"component_name": "Begin",
"params": map[string]any{
"mode": mode,
},
},
},
},
}
if params == nil {
params = map[string]any{}
}
if _, ok := params["security"]; !ok {
params["security"] = map[string]any{
"auth_type": "none",
"allow_anonymous": true,
}
}
for k, v := range params {
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)[k] = v
}
return &entity.UserCanvas{
ID: id,
UserID: userID,
CanvasCategory: "agent_canvas",
DSL: entity.JSONMap(dsl),
}
}
// webhookCtx builds a request carrying a test webhook credential and a user
// for the authenticated /webhook/test route.
func webhookCtx(method, path, body, contentType string) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
c.Request = httptest.NewRequest(method, path, reader)
query := c.Request.URL.Query()
query.Set("credential", "test-capability")
c.Request.URL.RawQuery = query.Encode()
pathParts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range pathParts {
if part == "agents" && i+1 < len(pathParts) {
c.Params = gin.Params{{Key: "canvas_id", Value: pathParts[i+1]}}
break
}
}
if contentType != "" {
c.Request.Header.Set("Content-Type", contentType)
}
c.Set("user", &entity.User{ID: "u-1"})
return c, w
}
func newWebhookTestAgentHandler(loader canvasLoader) *AgentHandler {
return &AgentHandler{
loader: loader,
webhookCredentials: &fakeAgentWebhookCredentialManager{
credential: entity.AgentWebhookCredential{UserID: "u-1", TenantID: "u-1"},
token: "test-capability",
},
}
}
func TestPublicWebhookRunsAsCredentialIdentityWithoutUserToken(t *testing.T) {
tenantID := "tenant-1"
canvas := makeWebhookCanvas("c1", "canvas-owner", "Webhook", map[string]any{
"execution_mode": "Streaming",
"schema": map[string]any{"query": map[string]any{
"properties": map[string]any{"value": map[string]any{"type": "string"}},
}},
})
canvas.TenantID = &tenantID
loader := &fakeCanvasLoader{canvas: canvas, runStarted: make(chan string, 1)}
credentials := &fakeAgentWebhookCredentialManager{
credential: entity.AgentWebhookCredential{UserID: "webhook-caller", TenantID: tenantID},
token: "external-capability",
}
h := &AgentHandler{loader: loader, webhookCredentials: credentials}
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Delete("user")
c.Request.URL.RawQuery = "credential=external-capability&value=visible"
h.Webhook(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s; want 200", w.Code, w.Body.String())
}
if credentials.resolveID != "c1" || credentials.resolveToken != "external-capability" {
t.Fatalf("credential lookup = (%q, %q), want path canvas and URL credential", credentials.resolveID, credentials.resolveToken)
}
if loader.loadUserID != "webhook-caller" {
t.Fatalf("canvas loaded as %q, want bound webhook caller", loader.loadUserID)
}
select {
case userID := <-loader.runStarted:
if userID != "webhook-caller" {
t.Fatalf("agent ran as %q, want bound webhook caller", userID)
}
case <-time.After(time.Second):
t.Fatal("webhook did not start the agent")
}
if subject, ok := permission.SubjectFromContext(loader.runContext); !ok || subject.TenantID != tenantID {
t.Fatalf("run tenant = %q, want %q", subject.TenantID, tenantID)
}
input := loader.runPayload["input"].(map[string]any)
query := input["query"].(map[string]any)
if _, leaked := query["credential"]; leaked {
t.Fatalf("webhook credential leaked into Agent query input: %#v", query)
}
if query["value"] != "visible" {
t.Fatalf("webhook query input = %#v, want the non-auth query field", query)
}
}
func TestPublicWebhookRejectsMissingOrInvalidCredential(t *testing.T) {
for _, token := range []string{"", "wrong-capability"} {
t.Run("token="+token, func(t *testing.T) {
loader := &fakeCanvasLoader{canvas: makeWebhookCanvas("c1", "canvas-owner", "Webhook", nil)}
credentials := &fakeAgentWebhookCredentialManager{token: "valid-capability"}
h := &AgentHandler{loader: loader, webhookCredentials: credentials}
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Delete("user")
c.Request.Header.Set("Authorization", "Bearer user-api-key")
if token != "" {
c.Request.URL.RawQuery = "credential=" + token
}
h.Webhook(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
}
if loader.runCalls.Load() != 0 || loader.loadUserID != "" {
t.Fatalf("invalid credential reached canvas load/run: user=%q runs=%d", loader.loadUserID, loader.runCalls.Load())
}
})
}
}
func TestWebhookCredentialManagementReturnsOneTimeURLAndRevokesCurrentUser(t *testing.T) {
manager := &fakeAgentWebhookCredentialManager{issueToken: "issued-capability"}
h := &AgentHandler{
loader: &fakeCanvasLoader{canvas: makeWebhookCanvas("c1", "u-1", "Webhook", nil)},
webhookCredentials: manager,
}
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook/credentials", "", "")
h.CreateWebhookCredential(c)
var response struct {
Code common.ErrorCode `json:"code"`
Data struct {
Credential string `json:"credential"`
WebhookURL string `json:"webhook_url"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode credential response: %v; body=%s", err, w.Body.String())
}
if w.Code != http.StatusOK || response.Code != common.CodeSuccess {
t.Fatalf("create status=%d code=%d body=%s", w.Code, response.Code, w.Body.String())
}
if response.Data.Credential != manager.issueToken || !strings.Contains(response.Data.WebhookURL, "credential=issued-capability") {
t.Fatalf("credential response = %+v, want one-time credential URL", response.Data)
}
c, w = webhookCtx("DELETE", "/api/v1/agents/c1/webhook/credentials", "", "")
h.RevokeWebhookCredential(c)
if w.Code != http.StatusOK || manager.revokedID != "c1" || manager.revokedUser != "u-1" {
t.Fatalf("revoke status=%d identity=(%q,%q), body=%s", w.Code, manager.revokedID, manager.revokedUser, w.Body.String())
}
}
// errBody extracts {code, message} from a 102 envelope response.
func errBody(t *testing.T, body []byte) (int, string) {
t.Helper()
var env struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode response: %v (body=%s)", err, body)
}
return env.Code, env.Message
}
// ---------- Phase 1: canvas loading / DataFlow rejection ----------
// TestWebhookHeaderSchemaAcceptsMixedCase verifies real request parsing and schema validation before dispatch.
func TestWebhookHeaderSchemaAcceptsMixedCase(t *testing.T) {
for _, path := range []string{"/api/v1/agents/c1/webhook", "/api/v1/agents/c1/webhook/test"} {
t.Run(path, func(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Streaming",
"schema": map[string]any{"headers": map[string]any{
"properties": map[string]any{"x-request-id": map[string]any{"type": "string"}},
"required": []string{"x-request-id"},
}},
})
loader := &fakeCanvasLoader{canvas: cv}
h := newWebhookTestAgentHandler(loader)
h.webhookTraceAppender = func(context.Context, string, time.Time, canvas.RunEvent) {}
c, w := webhookCtx("POST", path, `{}`, "application/json")
c.Request.Header.Set("X-Request-ID", "request-123")
h.Webhook(c)
if w.Code != http.StatusOK || loader.runCalls.Load() != 1 || !strings.Contains(w.Body.String(), `"success":true`) {
t.Fatalf("status=%d runs=%d response=%s", w.Code, loader.runCalls.Load(), w.Body.String())
}
})
}
}
func TestWebhookRunsAsCredentialCaller(t *testing.T) {
for _, executionMode := range []string{"Streaming", "Immediately"} {
t.Run(executionMode, func(t *testing.T) {
cv := makeWebhookCanvas("c1", "canvas-owner", "Webhook", map[string]any{
"execution_mode": executionMode,
})
loader := &fakeCanvasLoader{canvas: cv, runStarted: make(chan string, 1)}
h := newWebhookTestAgentHandler(loader)
h.webhookCredentials = &fakeAgentWebhookCredentialManager{
credential: entity.AgentWebhookCredential{UserID: "webhook-caller", TenantID: "canvas-owner"},
token: "test-capability",
}
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Delete("user")
h.Webhook(c)
if loader.loadUserID != "webhook-caller" {
t.Fatalf("canvas loaded as %q, want credential-bound caller", loader.loadUserID)
}
select {
case runUserID := <-loader.runStarted:
if runUserID != "webhook-caller" {
t.Fatalf("agent ran as %q, want credential-bound caller", runUserID)
}
case <-time.After(time.Second):
t.Fatal("webhook did not start the agent")
}
if loader.runCanvasID != cv.ID {
t.Fatalf("agent ran canvas %q, want %q", loader.runCanvasID, cv.ID)
}
if executionMode == "Streaming" && w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
})
}
}
// TestWebhook_RejectsUnknownCanvas pins the 102 "Canvas not found."
// envelope when LoadCanvasByID returns ErrUserCanvasNotFound. This is
// the deliberate divergence from mapAgentError (which would surface 103).
func TestWebhook_RejectsUnknownCanvas(t *testing.T) {
h := newWebhookTestAgentHandler(&fakeCanvasLoader{err: dao.ErrUserCanvasNotFound})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d (DataError)", code, common.CodeDataError)
}
if msg != "Canvas not found." {
t.Errorf("message = %q, want %q", msg, "Canvas not found.")
}
}
// TestWebhook_DataFlowRejected pins the second guard: a canvas whose category
// is DataFlow must NOT be triggered by an external webhook. The handler returns
// CodeDataError with "Dataflow can not be triggered by webhook." — mirroring
// Python agent_api.py:1786. DataFlow canvases are ingestion pipelines, not
// interactive agents, and have no chat/debug surface over the webhook entry.
func TestWebhook_DataFlowRejected(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", nil)
cv.CanvasCategory = "dataflow_canvas"
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d (CodeDataError)", code, common.CodeDataError)
}
if msg != "Dataflow can not be triggered by webhook." {
t.Errorf("message = %q, want %q", msg, "Dataflow can not be triggered by webhook.")
}
}
// TestWebhook_RejectsMissingWebhookConfig: DSL has no Begin with
// mode="Webhook".
func TestWebhook_RejectsMissingWebhookConfig(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Manual", nil) // wrong mode
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
if msg != "Webhook not configured for this agent." {
t.Errorf("message = %q", msg)
}
}
// ---------- Phase 5: method gate ----------
// TestWebhook_RejectsDisallowedMethod covers the methods list gate.
// methods:["POST"] + a GET request → 102.
func TestWebhook_RejectsDisallowedMethod(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"methods": []any{"POST"},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("GET", "/api/v1/agents/c1/webhook", ``, "")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
want := "HTTP method 'GET' not allowed for this webhook."
if msg != want {
t.Errorf("message = %q, want %q", msg, want)
}
}
// ---------- Phase 6: security ----------
// TestWebhook_TokenAuthPasses pins the happy path for token auth.
func TestWebhook_TokenAuthPasses(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"security": map[string]any{
"auth_type": "token",
"token": map[string]any{
"token_header": "X-Webhook-Token",
"token_value": "s3cret",
},
},
"execution_mode": "Immediately",
"response": map[string]any{
"status": 200,
},
})
loader := &fakeCanvasLoader{canvas: cv}
h := newWebhookTestAgentHandler(loader)
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Request.Header.Set("X-Webhook-Token", "s3cret")
h.Webhook(c)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
}
// TestWebhook_TokenAuthFails: header value mismatch → 102.
func TestWebhook_TokenAuthFails(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"security": map[string]any{
"auth_type": "token",
"token": map[string]any{
"token_header": "X-Webhook-Token",
"token_value": "s3cret",
},
},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Request.Header.Set("X-Webhook-Token", "wrong")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
if msg != "invalid token authentication" {
t.Errorf("message = %q, want %q", msg, "invalid token authentication")
}
}
// TestWebhook_BodySizeLimit: 1kb ceiling + ~2kb Content-Length → 102.
func TestWebhook_BodySizeLimit(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"security": map[string]any{
"max_body_size": "1kb",
},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook",
strings.Repeat("a", 2048), "text/plain")
h.Webhook(c)
code, _ := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
}
// TestWebhook_BodySizeLimitTooLarge rejects config bugs >10MB.
func TestWebhook_BodySizeLimitTooLarge(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"security": map[string]any{
"max_body_size": "11mb",
},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
if !strings.Contains(msg, "exceeds maximum") {
t.Errorf("message = %q, want contains 'exceeds maximum'", msg)
}
}
// TestWebhook_BasicAuthPasses / TestWebhook_BasicAuthFails exercise the
// HTTP Basic branch.
func TestWebhook_BasicAuthPasses(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"security": map[string]any{
"auth_type": "basic",
"basic_auth": map[string]any{
"username": "alice",
"password": "wonderland",
},
},
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Request.SetBasicAuth("alice", "wonderland")
h.Webhook(c)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
}
func TestWebhook_BasicAuthFails(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"security": map[string]any{
"auth_type": "basic",
"basic_auth": map[string]any{
"username": "alice",
"password": "wonderland",
},
},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
c.Request.SetBasicAuth("alice", "wrong")
h.Webhook(c)
code, _ := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
}
// ---------- Phase 8: schema extraction ----------
// TestWebhook_SchemaExtractionRequired: missing required field → 102.
func TestWebhook_SchemaExtractionRequired(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"schema": map[string]any{
"query": map[string]any{
"properties": map[string]any{
"q": map[string]any{"type": "string"},
},
"required": []any{"q"},
},
},
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook?other=v", ``, "")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
if !strings.Contains(msg, "missing required field") {
t.Errorf("message = %q, want contains 'missing required field'", msg)
}
}
// TestWebhook_SchemaExtractionTypeMismatch: non-numeric in number field.
func TestWebhook_SchemaExtractionTypeMismatch(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"schema": map[string]any{
"body": map[string]any{
"properties": map[string]any{
"n": map[string]any{"type": "number"},
},
"required": []any{"n"},
},
},
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{"n":"abc"}`, "application/json")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
if !strings.Contains(msg, "type mismatch") && !strings.Contains(msg, "auto-cast") {
t.Errorf("message = %q, want contains 'type mismatch' or 'auto-cast'", msg)
}
}
// ---------- Phase 9: dispatch ----------
// TestWebhook_ImmediatelyReturnsConfiguredStatus confirms the
// Immediately mode returns the configured status code and runs the
// canvas detached.
func TestWebhook_ImmediatelyReturnsConfiguredStatus(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Immediately",
"response": map[string]any{
"status": 201,
"body_template": `{"ok":true}`,
},
})
loader := &fakeCanvasLoader{
canvas: cv,
events: []canvas.RunEvent{
{Type: "message", Data: `{"content":"hi"}`},
{Type: "done", Data: ""},
},
}
h := newWebhookTestAgentHandler(loader)
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
if w.Code != http.StatusCreated {
t.Errorf("status = %d, want 201; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Errorf("body = %q, want contains '\"ok\":true'", w.Body.String())
}
}
// TestWebhook_DefaultModeAggregatesContent covers the non-Immediately
// path: streaming message + message_end events get concatenated.
func TestWebhook_DefaultModeAggregatesContent(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Streaming",
})
loader := &fakeCanvasLoader{
canvas: cv,
events: []canvas.RunEvent{
{Type: "message", Data: `{"content":"hello "}`},
{Type: "message", Data: `{"content":"world"}`},
{Type: "message_end", Data: `{"status":200}`},
{Type: "done", Data: ""},
},
}
h := newWebhookTestAgentHandler(loader)
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var body struct {
Message string `json:"message"`
Code int `json:"code"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Message != "hello world" {
t.Errorf("aggregated message = %q, want %q", body.Message, "hello world")
}
if body.Code != 200 {
t.Errorf("code = %d, want 200", body.Code)
}
}
func TestWebhook_DefaultModePropagatesTerminalEvents(t *testing.T) {
tests := []struct {
name string
event canvas.RunEvent
wantStatus int
wantMessage string
forbidden string
}{
{
name: "public error",
event: canvas.RunEvent{Type: "error", Data: `{"message":"retrieval backend failed"}`},
wantStatus: http.StatusInternalServerError,
wantMessage: "retrieval backend failed",
},
{
name: "internal error",
event: canvas.RunEvent{Type: "error", Data: `{"message":"postgres password=secret","kind":"internal"}`},
wantStatus: http.StatusInternalServerError,
wantMessage: canvas.InternalRunErrorMessage,
forbidden: "password=secret",
},
{
name: "cancelled",
event: canvas.RunEvent{Type: "cancelled", Data: `{"message":"Agent run was cancelled."}`},
wantStatus: http.StatusConflict,
wantMessage: "Agent run was cancelled.",
},
{
name: "waiting for user",
event: canvas.RunEvent{Type: "waiting_for_user", Data: `{"cpn_id":"input-1","tips":"Please choose an option."}`},
wantStatus: http.StatusConflict,
wantMessage: "Please choose an option.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Streaming",
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv, events: []canvas.RunEvent{tt.event}})
var trace []canvas.RunEvent
h.webhookTraceAppender = func(_ context.Context, _ string, _ time.Time, ev canvas.RunEvent) {
trace = append(trace, ev)
}
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook/test", `{}`, "application/json")
h.Webhook(c)
if w.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", w.Code, tt.wantStatus, w.Body.String())
}
var body struct {
Message string `json:"message"`
Success bool `json:"success"`
Code int `json:"code"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Success {
t.Fatal("terminal event must not be reported as success")
}
if body.Code != tt.wantStatus {
t.Errorf("code = %d, want %d", body.Code, tt.wantStatus)
}
if !strings.Contains(body.Message, tt.wantMessage) {
t.Errorf("message = %q, want contains %q", body.Message, tt.wantMessage)
}
if tt.forbidden != "" && strings.Contains(w.Body.String(), tt.forbidden) {
t.Fatalf("response leaked internal error details: %s", w.Body.String())
}
if tt.forbidden != "" {
for _, ev := range trace {
if strings.Contains(ev.Data, tt.forbidden) {
t.Fatalf("trace leaked internal error details: %+v", trace)
}
}
}
assertWebhookFinishedTrace(t, trace, false)
})
}
}
func TestWebhook_DefaultModeRedactsStartError(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Streaming",
})
startErr := errors.Join(errors.New("dial mysql password=secret"), service.ErrAgentStorageError)
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv, runErr: startErr})
var trace []canvas.RunEvent
h.webhookTraceAppender = func(_ context.Context, _ string, _ time.Time, ev canvas.RunEvent) {
trace = append(trace, ev)
}
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook/test", `{}`, "application/json")
h.Webhook(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), canvas.InternalRunErrorMessage) {
t.Errorf("body missing safe internal error message: %s", w.Body.String())
}
if strings.Contains(w.Body.String(), "password=secret") {
t.Fatalf("response leaked internal error details: %s", w.Body.String())
}
if len(trace) == 0 || strings.Contains(trace[0].Data, "password=secret") {
t.Fatalf("trace leaked internal error details: %+v", trace)
}
assertWebhookFinishedTrace(t, trace, false)
}
func TestRunWebhookDetachedAppendsFinishedTrace(t *testing.T) {
tests := []struct {
name string
events []canvas.RunEvent
runErr error
wantSuccess bool
forbidden string
}{
{
name: "success",
events: []canvas.RunEvent{
{Type: "message", Data: `{"content":"done"}`},
{Type: "done"},
},
wantSuccess: true,
},
{
name: "runner error",
events: []canvas.RunEvent{{Type: "error", Data: `{"message":"LLM failed"}`}},
wantSuccess: false,
},
{
name: "start error",
runErr: errors.Join(errors.New("redis password=secret"), service.ErrAgentStorageError),
wantSuccess: false,
forbidden: "password=secret",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", nil)
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv, events: tt.events, runErr: tt.runErr})
var trace []canvas.RunEvent
h.webhookTraceAppender = func(_ context.Context, _ string, _ time.Time, ev canvas.RunEvent) {
trace = append(trace, ev)
}
h.runWebhookDetached(t.Context(), "u-1", cv, map[string]any{}, true, time.Now())
assertWebhookFinishedTrace(t, trace, tt.wantSuccess)
if tt.forbidden != "" {
for _, ev := range trace {
if strings.Contains(ev.Data, tt.forbidden) {
t.Fatalf("trace leaked internal error details: %+v", trace)
}
}
}
})
}
}
func TestRunWebhookDetachedRecordsTimeoutTrace(t *testing.T) {
tests := []struct {
name string
loader *fakeCanvasLoader
}{
{name: "during start", loader: &fakeCanvasLoader{runErr: context.DeadlineExceeded}},
{name: "during run", loader: &fakeCanvasLoader{waitForCancellation: true}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", nil)
tt.loader.canvas = cv
h := newWebhookTestAgentHandler(tt.loader)
var trace []canvas.RunEvent
h.webhookTraceAppender = func(ctx context.Context, _ string, _ time.Time, ev canvas.RunEvent) {
if err := ctx.Err(); err != nil {
t.Errorf("trace context error = %v, want nil", err)
}
trace = append(trace, ev)
}
ctx, cancel := context.WithTimeout(
service.WithAgentSessionID(t.Context(), "session-1"),
time.Nanosecond,
)
defer cancel()
<-ctx.Done()
h.runWebhookDetachedWithContext(ctx, "u-1", cv, map[string]any{}, true, time.Now(), "session-1")
if len(trace) < 2 || trace[0].Type != "error" || !strings.Contains(trace[0].Data, "timed out") {
t.Fatalf("timeout trace = %+v, want error followed by failed finished event", trace)
}
assertWebhookFinishedTrace(t, trace, false)
})
}
}
func TestRunWebhookSyncRecordsCancelledTrace(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", nil)
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv, waitForCancellation: true})
var trace []canvas.RunEvent
h.webhookTraceAppender = func(ctx context.Context, _ string, _ time.Time, ev canvas.RunEvent) {
if err := ctx.Err(); err != nil {
t.Errorf("trace context error = %v, want nil", err)
}
trace = append(trace, ev)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
result := h.runWebhookSync(ctx, "u-1", cv, map[string]any{}, true, time.Now())
if result.status != http.StatusConflict {
t.Errorf("status = %d, want %d", result.status, http.StatusConflict)
}
if len(trace) < 2 || trace[0].Type != "cancelled" || !strings.Contains(trace[0].Data, "cancelled") {
t.Fatalf("cancelled trace = %+v, want cancelled followed by failed finished event", trace)
}
assertWebhookFinishedTrace(t, trace, false)
}
func TestWebhookBufferedTerminalEventAfterCancellationUsesCleanupContext(t *testing.T) {
tests := []struct {
name string
run func(*testing.T, *AgentHandler, context.Context, *entity.UserCanvas)
}{
{
name: "detached",
run: func(_ *testing.T, h *AgentHandler, ctx context.Context, cv *entity.UserCanvas) {
h.runWebhookDetachedWithContext(ctx, "u-1", cv, map[string]any{}, true, time.Now(), "session-1")
},
},
{
name: "sync",
run: func(t *testing.T, h *AgentHandler, ctx context.Context, cv *entity.UserCanvas) {
result := h.runWebhookSync(ctx, "u-1", cv, map[string]any{}, true, time.Now())
if result.status != http.StatusConflict {
t.Errorf("status = %d, want %d", result.status, http.StatusConflict)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", nil)
loader := &fakeCanvasLoader{
canvas: cv,
events: []canvas.RunEvent{{
Type: "cancelled",
Data: `{"message":"Agent run was cancelled."}`,
}},
}
h := newWebhookTestAgentHandler(loader)
var trace []canvas.RunEvent
h.webhookTraceAppender = func(ctx context.Context, _ string, _ time.Time, ev canvas.RunEvent) {
if err := ctx.Err(); err != nil {
t.Errorf("trace context error = %v, want nil", err)
}
trace = append(trace, ev)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
tt.run(t, h, ctx, cv)
if len(trace) < 2 || trace[0].Type != "cancelled" {
t.Fatalf("cancelled trace = %+v, want cancelled followed by failed finished event", trace)
}
assertWebhookFinishedTrace(t, trace, false)
})
}
}
func assertWebhookFinishedTrace(t *testing.T, events []canvas.RunEvent, wantSuccess bool) {
t.Helper()
if len(events) == 0 || events[len(events)-1].Type != "finished" {
t.Fatalf("trace must end with finished; trace=%+v", events)
}
finishedCount := 0
for _, ev := range events {
if ev.Type != "finished" {
continue
}
finishedCount++
var data struct {
Success bool `json:"success"`
}
if err := json.Unmarshal([]byte(ev.Data), &data); err != nil {
t.Fatalf("decode finished trace: %v; data=%q", err, ev.Data)
}
if data.Success != wantSuccess {
t.Errorf("finished success = %v, want %v", data.Success, wantSuccess)
}
}
if finishedCount != 1 {
t.Fatalf("finished event count = %d, want 1; trace=%+v", finishedCount, events)
}
}
// TestWebhook_InvalidResponseStatusRange rejects bad config (status 500).
func TestWebhook_InvalidResponseStatusRange(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Immediately",
"response": map[string]any{"status": 500},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{}`, "application/json")
h.Webhook(c)
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("code = %d, want %d", code, common.CodeDataError)
}
if !strings.Contains(msg, "must be between 200 and 399") {
t.Errorf("message = %q, want contains 'must be between 200 and 399'", msg)
}
}
// TestWebhook_FindWebhookBegin_NilDSL ensures the helper is defensive.
func TestWebhook_FindWebhookBegin_NilDSL(t *testing.T) {
if got := findWebhookBegin(nil); got != nil {
t.Errorf("findWebhookBegin(nil) = %v, want nil", got)
}
}
// TestWebhook_FindWebhookBegin_NoComponents covers DSL with no components.
func TestWebhook_FindWebhookBegin_NoComponents(t *testing.T) {
if got := findWebhookBegin(map[string]any{}); got != nil {
t.Errorf("findWebhookBegin(empty) = %v, want nil", got)
}
}
func TestWebhook_FindWebhookBegin_NormalizedCanvas(t *testing.T) {
canvas := makeWebhookCanvas("c1", "u-1", "Webhook", nil)
normalized := dslpkg.NormalizeForCanvas(canvas.DSL)
params := findWebhookBegin(normalized)
if params == nil || params["mode"] != "Webhook" {
t.Fatalf("findWebhookBegin(normalized canvas) = %v, want Webhook params", params)
}
}
// ---------- Regression tests for issues raised in code review ----------
type webhookReadErrorCase struct {
contentType string
failure string
}
// TestWebhookRejectsBodyReadErrors checks that neither execution mode starts an Agent after a failed read.
func TestWebhookRejectsBodyReadErrors(t *testing.T) {
requests := []webhookReadErrorCase{
{"application/json", "oversized"},
{"application/json", "interrupted"},
{"text/plain", "oversized"},
{"text/plain", "interrupted"},
{"application/x-www-form-urlencoded", "oversized"},
{"application/x-www-form-urlencoded", "interrupted"},
}
targets := []struct {
mode string
path string
}{
{"Immediately", "/api/v1/agents/c1/webhook"},
{"Immediately", "/api/v1/agents/c1/webhook/test"},
{"Streaming", "/api/v1/agents/c1/webhook"},
{"Streaming", "/api/v1/agents/c1/webhook/test"},
}
for _, request := range requests {
for _, target := range targets {
name := request.contentType + "/" + target.mode + "/" + target.path + "/" + request.failure
t.Run(name, func(t *testing.T) {
assertWebhookRejectsBodyReadError(t, request, target.mode, target.path)
})
}
}
}
// assertWebhookRejectsBodyReadError checks the response and absence of Agent dispatch.
func assertWebhookRejectsBodyReadError(t *testing.T, request webhookReadErrorCase, mode, path string) {
t.Helper()
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": mode,
"security": map[string]any{
"auth_type": "none", "allow_anonymous": true, "max_body_size": "1kb",
},
})
loader := &fakeCanvasLoader{canvas: cv}
h := newWebhookTestAgentHandler(loader)
c, w := webhookCtx("POST", path, "", request.contentType)
// Exercise the stream limit, not the Content-Length pre-check.
c.Request.ContentLength = -1
c.Request.TransferEncoding = []string{"chunked"}
body := `{"value":"ok"}`
if request.contentType == "application/x-www-form-urlencoded" {
body = "value=ok"
}
var reader io.Reader
wantMessage := "body too large"
if request.failure == "oversized" {
reader = strings.NewReader(body + strings.Repeat(" ", 1001))
} else {
// Even a valid prefix must be rejected if the stream fails afterward.
reader = io.MultiReader(strings.NewReader(body), iotest.ErrReader(io.ErrUnexpectedEOF))
wantMessage = "unexpected EOF"
}
c.Request.Body = io.NopCloser(reader)
h.Webhook(c)
if w.Code != http.StatusOK {
t.Errorf("HTTP status = %d, want existing 200 error envelope", w.Code)
}
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) || !strings.Contains(msg, wantMessage) {
t.Errorf("response = %s, want data error containing %q", w.Body.String(), wantMessage)
}
if calls := loader.runCalls.Load(); calls != 0 {
t.Errorf("Agent started %d times after body read failure", calls)
}
}
// TestParseWebhookRequestReadErrors verifies that partial payloads are discarded and error causes survive wrapping.
func TestParseWebhookRequestReadErrors(t *testing.T) {
for _, contentType := range []string{"application/json", "text/plain", "application/x-www-form-urlencoded"} {
for _, oversized := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/oversized=%t", contentType, oversized), func(t *testing.T) {
c, w := webhookCtx("POST", "/", "", contentType)
body := `{"value":"ok"}`
if contentType == "application/x-www-form-urlencoded" {
body = "value=ok"
}
c.Request.Body = io.NopCloser(io.MultiReader(strings.NewReader(body), iotest.ErrReader(io.ErrUnexpectedEOF)))
if oversized {
c.Request.Body = http.MaxBytesReader(w, io.NopCloser(strings.NewReader(body+strings.Repeat(" ", 1001))), 1000)
}
parsed, err := parseWebhookRequest("", c)
if err == nil || parsed != nil {
t.Fatalf("parsed = %v, err = %v; want no payload and a read error", parsed, err)
}
if oversized {
var sizeErr *http.MaxBytesError
if !errors.As(err, &sizeErr) {
t.Fatalf("error = %v, want MaxBytesError", err)
}
} else if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("error = %v, want unexpected EOF", err)
}
})
}
}
}
// TestParseWebhookRequestAtBodyLimit keeps the configured size limit inclusive for all body formats.
func TestParseWebhookRequestAtBodyLimit(t *testing.T) {
for _, contentType := range []string{"application/json", "text/plain", "application/x-www-form-urlencoded"} {
t.Run(contentType, func(t *testing.T) {
want := strings.Repeat("a", 988)
body := `{"value":"` + want + `"}`
if contentType == "application/x-www-form-urlencoded" {
want = strings.Repeat("a", 994)
body = "value=" + want
}
if len(body) != 1000 {
t.Fatalf("body length = %d, want 1000", len(body))
}
c, w := webhookCtx("POST", "/", body, contentType)
c.Request.ContentLength = -1
c.Request.Body = http.MaxBytesReader(w, c.Request.Body, 1000)
parsed, err := parseWebhookRequest("", c)
if err != nil {
t.Fatalf("parse body at limit: %v", err)
}
value, ok := parsed["body"].(map[string]any)["value"].(string)
if !ok || value != want {
t.Fatalf("parsed body value = %q", value)
}
})
}
}
//
// These three tests cover the behaviour the implementation summary
// claimed but did NOT actually enforce. They ensure the dispatch path
// returns the documented envelope for multipart and content-type
// mismatch cases.
// TestWebhook_MultipartReturns501 pins the contract that
// multipart/form-data uploads are refused with HTTP 501 BEFORE the
// schema-extraction phase runs. Before the fix, the handler silently
// stuffed __multipart_unsupported__ into body and continued; operators
// saw a confusing schema-validation error instead of the documented
// "not implemented" envelope.
func TestWebhook_MultipartReturns501(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook",
"--boundary\r\nContent-Disposition: form-data; name=\"k\"\r\n\r\nv\r\n--boundary--\r\n",
"multipart/form-data; boundary=boundary")
h.Webhook(c)
if w.Code != http.StatusNotImplemented {
t.Errorf("status = %d, want 501; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "not supported") {
t.Errorf("body should mention 'not supported', got %q", w.Body.String())
}
}
// TestWebhook_ContentTypeMismatchReturns102 pins the contract that
// content_types whitelist enforces a HARD REJECT (HTTP 102 envelope),
// matching python agent_api.py:1839-1842. Before the fix, the
// mismatch was silently recorded as __content_type_mismatch__ in the
// body and the request flowed into schema validation — a real
// whitelist bypass.
func TestWebhook_ContentTypeMismatchReturns102(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"content_types": "application/json",
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
// text/plain where the config demands application/json.
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", "raw body", "text/plain")
h.Webhook(c)
if w.Code != http.StatusOK {
t.Fatalf("envelope status = %d, want 200 (the envelope itself succeeds; the code field carries 102)", w.Code)
}
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("envelope code = %d, want %d (CodeDataError)", code, common.CodeDataError)
}
if !strings.Contains(msg, "invalid content-type") || !strings.Contains(msg, "application/json") {
t.Errorf("message = %q, want contains 'invalid content-type' and 'application/json'", msg)
}
}
// TestWebhook_ContentTypesEmptyAllowsAnything confirms the inverse:
// when webhook_cfg does NOT set content_types, any Content-Type is
// accepted (matches python: agent_api.py:1839 only raises when the
// config actually sets content_types).
func TestWebhook_ContentTypesEmptyAllowsAnything(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
// content_types deliberately omitted
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
loader := &fakeCanvasLoader{canvas: cv}
h := newWebhookTestAgentHandler(loader)
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{"k":"v"}`, "text/plain")
h.Webhook(c)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
}
// TestWebhook_ContentTypeMissingHeaderRejected pins the gap from the
// review: when content_types is configured, a request that OMITS the
// Content-Type header entirely must be rejected with 102. The python
// reference has a `if ctype and ...` short-circuit that lets such
// requests through — a real whitelist bypass. The Go port tightens
// this: an operator who configured content_types gets an enforceable
// contract; the caller MUST send the header.
func TestWebhook_ContentTypeMissingHeaderRejected(t *testing.T) {
cv := makeWebhookCanvas("c1", "u-1", "Webhook", map[string]any{
"content_types": "application/json",
"execution_mode": "Immediately",
"response": map[string]any{"status": 200},
})
h := newWebhookTestAgentHandler(&fakeCanvasLoader{canvas: cv})
c, w := webhookCtx("POST", "/api/v1/agents/c1/webhook", `{"k":"v"}`, "")
// ↑ "" content-type means the header is absent. gin will not
// produce a default Content-Type on a POST so this is a clean
// "no header" test case.
h.Webhook(c)
if w.Code != http.StatusOK {
t.Fatalf("envelope status = %d, want 200 (the envelope itself succeeds; the code field carries 102)", w.Code)
}
code, msg := errBody(t, w.Body.Bytes())
if code != int(common.CodeDataError) {
t.Errorf("envelope code = %d, want %d (CodeDataError)", code, common.CodeDataError)
}
if !strings.Contains(msg, "invalid content-type") {
t.Errorf("message = %q, want contains 'invalid content-type'", msg)
}
if !strings.Contains(msg, "application/json") {
t.Errorf("message = %q, want contains 'application/json'", msg)
}
}
// (No helper needed at the bottom of this file; helper functions
// inline above.)
// _unused previously lived here as a placeholder; deleted during
// cleanup (code-review MEDIUM-2).