mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-24 17:36:47 +08:00
@@ -27,10 +27,8 @@ var (
|
||||
ErrComponentNotFound = errors.New("dsl: component not found")
|
||||
|
||||
// ErrMissingInputForm is returned when the component exists
|
||||
// but has no `obj.input_form` dict. The python Canvas returns
|
||||
// None in this case; we surface 102 "component has no
|
||||
// input_form" instead.
|
||||
ErrMissingInputForm = errors.New("dsl: component has no input_form")
|
||||
// but has no `obj.params.inputs` dict.
|
||||
ErrMissingInputForm = errors.New("dsl: component has no params.inputs")
|
||||
|
||||
// ErrMalformedDSL is returned for structural problems — nil
|
||||
// dsl, missing components map, wrong types. Distinct from
|
||||
|
||||
@@ -24,23 +24,21 @@ package dsl
|
||||
import "fmt"
|
||||
|
||||
// ExtractComponentInputForm returns the input-form schema dict stored at
|
||||
// `dsl["components"][componentID]["obj"]["input_form"]`.
|
||||
// `dsl["components"][componentID]["obj"]["params"]["inputs"]`.
|
||||
//
|
||||
// This is the Go equivalent of the python
|
||||
// `Canvas.get_component_input_form(component_id)` method
|
||||
// (api/agent/canvas.py:163) which reads the same path. The python
|
||||
// version walks the live Canvas object; we walk the raw DSL map
|
||||
// directly because the Go Canvas type does not expose an
|
||||
// introspection API (see plan §Gap C — there is no `GetComponent` on
|
||||
// the runtime Canvas type).
|
||||
// which calls component.get_input_form(). For the Begin component,
|
||||
// that returns the live component's `_param.inputs`, initialised from
|
||||
// the raw DSL `obj.params.inputs`.
|
||||
//
|
||||
// Returns:
|
||||
// - the form-schema dict if present and well-typed
|
||||
// - ErrComponentNotFound if the componentID is missing from dsl
|
||||
// - ErrMissingInputForm if the component exists but has no input_form
|
||||
// - ErrMissingInputForm if the component exists but has no params.inputs
|
||||
// - ErrMalformedDSL if the field is present but the wrong type
|
||||
//
|
||||
// Type errors (input_form is e.g. a list or a string) are NOT
|
||||
// Type errors (params or inputs is e.g. a list or a string) are NOT
|
||||
// collapsed into ErrMissingInputForm — they would mask a contract
|
||||
// violation in the DSL and let DebugComponent run against corrupt
|
||||
// data. CodeRabbit PR review #1 on PR #16403.
|
||||
@@ -53,13 +51,21 @@ func ExtractComponentInputForm(dsl map[string]any, componentID string) (map[stri
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q has no obj", ErrMalformedDSL, componentID)
|
||||
}
|
||||
rawForm, exists := obj["input_form"]
|
||||
rawParams, exists := obj["params"]
|
||||
if !exists || rawParams == nil {
|
||||
return nil, fmt.Errorf("%w: component %q has no params.inputs", ErrMissingInputForm, componentID)
|
||||
}
|
||||
params, ok := rawParams.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q params is not a dict", ErrMalformedDSL, componentID)
|
||||
}
|
||||
rawForm, exists := params["inputs"]
|
||||
if !exists || rawForm == nil {
|
||||
return nil, fmt.Errorf("%w: component %q has no input_form", ErrMissingInputForm, componentID)
|
||||
return nil, fmt.Errorf("%w: component %q has no params.inputs", ErrMissingInputForm, componentID)
|
||||
}
|
||||
form, ok := rawForm.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: component %q input_form is not a dict", ErrMalformedDSL, componentID)
|
||||
return nil, fmt.Errorf("%w: component %q params.inputs is not a dict", ErrMalformedDSL, componentID)
|
||||
}
|
||||
return form, nil
|
||||
}
|
||||
@@ -165,11 +171,9 @@ func FindBeginComponentID(dsl map[string]any) (string, error) {
|
||||
return "", fmt.Errorf("%w: Begin component", ErrComponentNotFound)
|
||||
}
|
||||
|
||||
// ExtractPrologue mirrors python Canvas.get_prologue
|
||||
// (api/agent/canvas.py:190) — returns the "prologue" string stored at
|
||||
// dsl["components"][<begin_id>]["obj"]["prologue"]. Reuses the
|
||||
// shared navigateToComponent helper so the addressing rule is
|
||||
// consistent with ExtractComponentInputForm.
|
||||
// ExtractPrologue mirrors python Canvas.get_prologue, which returns
|
||||
// the Begin component's `_param.prologue`. In raw DSL terms this is
|
||||
// dsl["components"][<begin_id>]["obj"]["params"]["prologue"].
|
||||
func ExtractPrologue(dsl map[string]any) (string, error) {
|
||||
id, err := FindBeginComponentID(dsl)
|
||||
if err != nil {
|
||||
@@ -180,13 +184,13 @@ func ExtractPrologue(dsl map[string]any) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
obj, _ := comp["obj"].(map[string]any)
|
||||
s, _ := obj["prologue"].(string)
|
||||
params, _ := obj["params"].(map[string]any)
|
||||
s, _ := params["prologue"].(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ExtractMode mirrors python Canvas.get_mode (api/agent/canvas.py:200).
|
||||
// Returns the canvas mode (e.g. "Agent" / "DataFlow") stored at
|
||||
// dsl["components"][<begin_id>]["obj"]["mode"].
|
||||
// ExtractMode mirrors python Canvas.get_mode. In raw DSL terms this is
|
||||
// dsl["components"][<begin_id>]["obj"]["params"]["mode"].
|
||||
func ExtractMode(dsl map[string]any) (string, error) {
|
||||
id, err := FindBeginComponentID(dsl)
|
||||
if err != nil {
|
||||
@@ -197,6 +201,7 @@ func ExtractMode(dsl map[string]any) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
obj, _ := comp["obj"].(map[string]any)
|
||||
s, _ := obj["mode"].(string)
|
||||
params, _ := obj["params"].(map[string]any)
|
||||
s, _ := params["mode"].(string)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ func happyDSL() map[string]any {
|
||||
"component_name": "Begin",
|
||||
"params": map[string]any{
|
||||
"mode": "Manual",
|
||||
},
|
||||
"input_form": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"inputs": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -78,7 +78,7 @@ func TestExtractComponentInputForm_MissingObj(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractComponentInputForm_MissingInputForm(t *testing.T) {
|
||||
// "answer" has obj but no input_form.
|
||||
// "answer" has obj but no params.inputs.
|
||||
_, err := ExtractComponentInputForm(happyDSL(), "answer")
|
||||
if !errors.Is(err, ErrMissingInputForm) {
|
||||
t.Errorf("err = %v, want ErrMissingInputForm", err)
|
||||
@@ -219,7 +219,7 @@ func TestFindBeginComponentID_NilDSL(t *testing.T) {
|
||||
// TestExtractPrologue_HappyPath pins the prologue lookup path.
|
||||
func TestExtractPrologue_HappyPath(t *testing.T) {
|
||||
dsl := happyDSL()
|
||||
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["prologue"] = "hello"
|
||||
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)["prologue"] = "hello"
|
||||
got, err := ExtractPrologue(dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
@@ -229,6 +229,29 @@ func TestExtractPrologue_HappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrologue_FromBeginParams(t *testing.T) {
|
||||
dsl := map[string]any{
|
||||
"components": map[string]any{
|
||||
"begin": map[string]any{
|
||||
"obj": map[string]any{
|
||||
"component_name": "Begin",
|
||||
"params": map[string]any{
|
||||
"mode": "conversational",
|
||||
"prologue": "Welcome from params",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got, err := ExtractPrologue(dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if got != "Welcome from params" {
|
||||
t.Errorf("prologue = %q, want Welcome from params", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractPrologue_NotFound pins that a missing begin component
|
||||
// returns ErrComponentNotFound (the service layer turns this into
|
||||
// empty-string fallback).
|
||||
@@ -244,7 +267,7 @@ func TestExtractPrologue_NotFound(t *testing.T) {
|
||||
// TestExtractMode_HappyPath pins the mode lookup path.
|
||||
func TestExtractMode_HappyPath(t *testing.T) {
|
||||
dsl := happyDSL()
|
||||
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["mode"] = "Agent"
|
||||
dsl["components"].(map[string]any)["begin"].(map[string]any)["obj"].(map[string]any)["params"].(map[string]any)["mode"] = "Agent"
|
||||
got, err := ExtractMode(dsl)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
|
||||
@@ -76,7 +76,7 @@ func (h *AgentHandler) GetComponentInputForm(c *gin.Context) {
|
||||
}
|
||||
|
||||
// componentInputForm returns the input-form schema for a single component.
|
||||
// It first tries the static input_form stored in the DSL; if the component
|
||||
// It first tries the params.inputs stored in the DSL; if the component
|
||||
// does not define one (e.g. Agent components that generate it dynamically),
|
||||
// it instantiates the runtime component and calls its GetInputForm method.
|
||||
// This mirrors Python's Canvas.get_component_input_form which invokes the
|
||||
@@ -238,7 +238,7 @@ func mapDSLError(c *gin.Context, componentID string, err error) {
|
||||
case errors.Is(err, dsl.ErrComponentNotFound):
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "component not found: "+componentID)
|
||||
case errors.Is(err, dsl.ErrMissingInputForm):
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "component has no input_form: "+componentID)
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "component has no params.inputs: "+componentID)
|
||||
case errors.Is(err, dsl.ErrMalformedDSL):
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "malformed dsl: "+err.Error())
|
||||
default:
|
||||
|
||||
@@ -49,8 +49,7 @@ func componentCtx(t *testing.T, method, path, body string) (*gin.Context, *httpt
|
||||
}
|
||||
|
||||
// beginCanvas returns a UserCanvas whose DSL has a single Begin
|
||||
// component with both an input_form and a params block. Used for
|
||||
// happy-path tests.
|
||||
// component with a params block. Used for happy-path tests.
|
||||
func beginCanvas() *entity.UserCanvas {
|
||||
return &entity.UserCanvas{
|
||||
ID: "c1",
|
||||
@@ -61,9 +60,9 @@ func beginCanvas() *entity.UserCanvas {
|
||||
"component_name": "Begin",
|
||||
"params": map[string]any{
|
||||
"mode": "Manual",
|
||||
},
|
||||
"input_form": map[string]any{
|
||||
"query": map[string]any{"type": "string"},
|
||||
"inputs": map[string]any{
|
||||
"query": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -205,8 +204,8 @@ func TestGetComponentInputForm_BrowserDynamicInputForm(t *testing.T) {
|
||||
if prompts["name"] != "Prompts" {
|
||||
t.Errorf("data.prompts.name = %v, want Prompts", prompts["name"])
|
||||
}
|
||||
if prompts["type"] != "text" {
|
||||
t.Errorf("data.prompts.type = %v, want text", prompts["type"])
|
||||
if prompts["type"] != "paragraph" {
|
||||
t.Errorf("data.prompts.type = %v, want paragraph", prompts["type"])
|
||||
}
|
||||
uploadSources, ok := env.Data["upload_sources"].(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -268,7 +267,7 @@ func TestGetComponentInputForm_NoInputForm(t *testing.T) {
|
||||
"answer": map[string]any{
|
||||
"obj": map[string]any{
|
||||
"component_name": "Answer",
|
||||
// no input_form
|
||||
// no params.inputs
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -287,8 +286,8 @@ func TestGetComponentInputForm_NoInputForm(t *testing.T) {
|
||||
if code != 102 {
|
||||
t.Errorf("code = %d, want 102; msg=%q", code, msg)
|
||||
}
|
||||
if !strings.Contains(msg, "no input_form") {
|
||||
t.Errorf("msg = %q, want 'no input_form'", msg)
|
||||
if !strings.Contains(msg, "no params.inputs") {
|
||||
t.Errorf("msg = %q, want 'no params.inputs'", msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,25 +96,11 @@ func (h *AuthHandler) BetaAuthMiddleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// Fall back to beta API token (public bot access). The
|
||||
// middleware also looks up the APIToken directly so the
|
||||
// downstream handler can read its DialogID (the real
|
||||
// agent_id) without re-parsing the Authorization header.
|
||||
// Mirrors the python
|
||||
// `APIToken.query(beta=token).dialog_id` lookup in
|
||||
// bot_api.py:agent_bot_logs.
|
||||
// Fall back to beta API token (public bot access).
|
||||
if u, code, err := h.userService.GetUserByBetaAPIToken(ctx, auth); err == nil && code == common.CodeSuccess {
|
||||
c.Set("user", u)
|
||||
if tok, terr := h.userService.GetAPITokenByBeta(ctx, auth); terr == nil && tok != nil && tok.DialogID != nil {
|
||||
// tok.DialogID is *string (nullable in the schema), but
|
||||
// downstream handlers (GetAgentbotLogs, GetAgentLogs)
|
||||
// read "agent_id" with agentID.(string) — they cannot
|
||||
// type-assert a *string. Dereference and gate on nil so a
|
||||
// row with a NULL dialog_id still surfaces the
|
||||
// "not bound" sentinel rather than silently leaking the
|
||||
// pointer (which would later fail the string assertion).
|
||||
c.Set("agent_id", *tok.DialogID)
|
||||
c.Set("api_token", tok)
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
|
||||
@@ -256,34 +256,33 @@ func (h *BotHandler) ChatbotCompletion(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAgentbotLogs GET /api/v1/agentbots/<shared_id>/logs/<message_id>
|
||||
// GetAgentbotLogs GET /api/v1/agentbots/<agent_id>/logs/<message_id>
|
||||
//
|
||||
// Beta-token sibling of GetAgentLogs. The shared/embedded chat
|
||||
// page's "Thinking" button hits this endpoint because the share
|
||||
// flow authenticates with a beta APIToken (no session JWT) and
|
||||
// the regular /api/v1/agents/<id>/logs/<msg> requires @login_required.
|
||||
// Mirrors python bot_api.py:agent_bot_logs (PR #15238).
|
||||
//
|
||||
// The <shared_id> path segment is the value the client passed in
|
||||
// the URL (typically the beta token in the share flow); the real
|
||||
// agent_id used to build the Redis key
|
||||
// (`<agent_id>-<message_id>-logs`) is read from the APIToken
|
||||
// looked up by the beta middleware and stashed in the gin
|
||||
// context as "agent_id". The endpoint never trusts the URL
|
||||
// segment for the data lookup — using the middleware-resolved
|
||||
// agent_id prevents a probe that swaps a victim's shared_id to
|
||||
// read another agent's logs.
|
||||
// Mirrors python bot_api.py:agent_bot_logs.
|
||||
func (h *BotHandler) GetAgentbotLogs(c *gin.Context) {
|
||||
if _, code, msg := GetUser(c); code != common.CodeSuccess {
|
||||
common.ResponseWithCodeData(c, code, nil, msg)
|
||||
return
|
||||
}
|
||||
agentID, _ := c.Get("agent_id")
|
||||
agentIDStr, _ := agentID.(string)
|
||||
agentIDStr := c.Param("agent_id")
|
||||
if agentIDStr == "" {
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "agent_id is required")
|
||||
return
|
||||
}
|
||||
boundAgentID, _ := c.Get("agent_id")
|
||||
boundAgentIDStr, _ := boundAgentID.(string)
|
||||
if boundAgentIDStr == "" {
|
||||
common.ResponseWithCodeData(c, common.CodeDataError, nil, "API token is not bound to an agent.")
|
||||
return
|
||||
}
|
||||
if boundAgentIDStr != agentIDStr {
|
||||
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, "API token is not authorized for this agent.")
|
||||
return
|
||||
}
|
||||
messageID := c.Param("message_id")
|
||||
if messageID == "" {
|
||||
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "message_id is required")
|
||||
|
||||
@@ -815,6 +815,46 @@ func TestBotMiddleware_NonBearerRegularToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMiddleware_BetaTokenBindsAgentID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
agentID := "agent-bound"
|
||||
stub := &stubUserTokenResolver{
|
||||
getUserByBetaAPITokenFn: func(ctx context.Context, auth string) (*entity.User, common.ErrorCode, error) {
|
||||
if auth != "beta-token" {
|
||||
t.Errorf("GetUserByBetaAPIToken called with %q, want beta-token", auth)
|
||||
}
|
||||
return &entity.User{ID: "u-beta"}, common.CodeSuccess, nil
|
||||
},
|
||||
getAPITokenByBetaFn: func(ctx context.Context, auth string) (*entity.APIToken, error) {
|
||||
if auth != "beta-token" {
|
||||
t.Errorf("GetAPITokenByBeta called with %q, want beta-token", auth)
|
||||
}
|
||||
return &entity.APIToken{DialogID: &agentID}, nil
|
||||
},
|
||||
}
|
||||
r := gin.New()
|
||||
ah := &AuthHandler{userService: stub}
|
||||
g := r.Group("/api/v1")
|
||||
g.Use(ah.BetaAuthMiddleware())
|
||||
var seenAgentID string
|
||||
g.GET("/x", func(c *gin.Context) {
|
||||
rawAgentID, _ := c.Get("agent_id")
|
||||
seenAgentID, _ = rawAgentID.(string)
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/x", nil)
|
||||
req.Header.Set("Authorization", "beta-token")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
if seenAgentID != agentID {
|
||||
t.Fatalf("agent_id = %q, want %q", seenAgentID, agentID)
|
||||
}
|
||||
}
|
||||
|
||||
// stubUserTokenResolver implements userTokenResolver for tests.
|
||||
// Each call site sets only the methods it needs; unset methods
|
||||
// return safe defaults (CodeUnauthorized so the middleware
|
||||
@@ -968,14 +1008,13 @@ func TestDownloadAttachment_Unauth(t *testing.T) {
|
||||
// resolve via GetUserByToken. Both branches must reject
|
||||
// with 401.
|
||||
g.Use(func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, "Authorization required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if u, code, err := stub.GetUserByToken(ctx, auth); err != nil || code != common.CodeSuccess {
|
||||
if u, code, err := stub.GetUserByToken(c.Request.Context(), auth); err != nil || code != common.CodeSuccess {
|
||||
common.ResponseWithCodeData(c, common.CodeUnauthorized, nil, "Invalid auth credentials")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -1112,13 +1151,7 @@ func inlineRegisterAgentRoutes(g *gin.RouterGroup, h *AgentHandler) {
|
||||
g.GET("/attachments/:attachment_id/download", h.DownloadAttachment)
|
||||
}
|
||||
|
||||
// TestGetAgentbotLogs_RequiresAgentIDInContext guards PR #15238:
|
||||
// the shared/embedded "Thinking" endpoint requires the beta
|
||||
// middleware to have stashed the APIToken.DialogID as "agent_id"
|
||||
// in the gin context. Without it, the handler cannot build the
|
||||
// Redis key and must return the "API token is not bound to an
|
||||
// agent." error — never read the URL's <shared_id> for the lookup.
|
||||
func TestGetAgentbotLogs_RequiresAgentIDInContext(t *testing.T) {
|
||||
func TestGetAgentbotLogs_MissingRouteAgentID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1132,19 +1165,76 @@ func TestGetAgentbotLogs_RequiresAgentIDInContext(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Code != int(common.CodeArgumentError) {
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.CodeArgumentError)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "agent_id") {
|
||||
t.Errorf("message = %q, want it to mention 'agent_id'", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentbotLogs_RequiresBoundAgentID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET",
|
||||
"/api/v1/agentbots/agent-a/logs/msg-1", nil)
|
||||
c.Set("user", &entity.User{ID: "u1"})
|
||||
c.Params = gin.Params{
|
||||
{Key: "agent_id", Value: "agent-a"},
|
||||
{Key: "message_id", Value: "msg-1"},
|
||||
}
|
||||
|
||||
h := NewBotHandler(nil)
|
||||
h.GetAgentbotLogs(c)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Code != int(common.CodeDataError) {
|
||||
t.Errorf("code = %d, want %d (CodeDataError)", resp.Code, common.CodeDataError)
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.CodeDataError)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "not bound") {
|
||||
t.Errorf("message = %q, want it to mention 'not bound'", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAgentbotLogs_CrossAgentDenied(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET",
|
||||
"/api/v1/agentbots/agent-b/logs/msg-1", nil)
|
||||
c.Set("user", &entity.User{ID: "u1"})
|
||||
c.Set("agent_id", "agent-a")
|
||||
c.Params = gin.Params{
|
||||
{Key: "agent_id", Value: "agent-b"},
|
||||
{Key: "message_id", Value: "msg-1"},
|
||||
}
|
||||
|
||||
h := NewBotHandler(nil)
|
||||
h.GetAgentbotLogs(c)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp.Code != int(common.CodeUnauthorized) {
|
||||
t.Errorf("code = %d, want %d", resp.Code, common.CodeUnauthorized)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "not authorized") {
|
||||
t.Errorf("message = %q, want it to mention 'not authorized'", resp.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAgentbotLogs_MissingMessageID asserts the param contract:
|
||||
// message_id is required (used to build the Redis key).
|
||||
func TestGetAgentbotLogs_MissingMessageID(t *testing.T) {
|
||||
@@ -1155,6 +1245,7 @@ func TestGetAgentbotLogs_MissingMessageID(t *testing.T) {
|
||||
"/api/v1/agentbots/shared-x/logs/", nil)
|
||||
c.Set("user", &entity.User{ID: "u1"})
|
||||
c.Set("agent_id", "agent-real")
|
||||
c.Params = gin.Params{{Key: "agent_id", Value: "agent-real"}}
|
||||
// Gin's path param extraction returns "" for a missing
|
||||
// segment so the handler must reject with CodeArgumentError.
|
||||
|
||||
|
||||
@@ -187,6 +187,78 @@ func TestBotService_AgentbotInputs_CrossTenantDenied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotService_AgentbotInputsReadsBeginParams(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
if err := db.AutoMigrate(
|
||||
&entity.UserCanvas{},
|
||||
&entity.UserTenant{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
orig := dao.DB
|
||||
dao.DB = db
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
if err := db.Create(&entity.UserTenant{
|
||||
ID: "ut-owner",
|
||||
UserID: "owner-1",
|
||||
TenantID: "tenant-1",
|
||||
Role: "owner",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed tenant: %v", err)
|
||||
}
|
||||
|
||||
title := "Agent"
|
||||
avatar := "avatar.png"
|
||||
if err := db.Create(&entity.UserCanvas{
|
||||
ID: "agent-1",
|
||||
UserID: "owner-1",
|
||||
Title: &title,
|
||||
Avatar: &avatar,
|
||||
CanvasCategory: "agent_canvas",
|
||||
DSL: entity.JSONMap{
|
||||
"components": map[string]any{
|
||||
"begin": map[string]any{
|
||||
"obj": map[string]any{
|
||||
"component_name": "Begin",
|
||||
"params": map[string]any{
|
||||
"mode": "conversational",
|
||||
"prologue": "Welcome from the agent",
|
||||
"inputs": map[string]any{
|
||||
"topic": map[string]any{"type": "line", "name": "Topic"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed canvas: %v", err)
|
||||
}
|
||||
|
||||
svc := NewBotService(nil, nil)
|
||||
gotTitle, gotAvatar, prologue, mode, inputs, code, err := svc.AgentbotInputs(
|
||||
context.Background(), "owner-1", "agent-1")
|
||||
if err != nil {
|
||||
t.Fatalf("AgentbotInputs: %v", err)
|
||||
}
|
||||
if code != common.CodeSuccess {
|
||||
t.Fatalf("code = %d, want %d", code, common.CodeSuccess)
|
||||
}
|
||||
if gotTitle != title || gotAvatar != avatar {
|
||||
t.Fatalf("title/avatar = %q/%q, want %q/%q", gotTitle, gotAvatar, title, avatar)
|
||||
}
|
||||
if prologue != "Welcome from the agent" {
|
||||
t.Errorf("prologue = %q, want Welcome from the agent", prologue)
|
||||
}
|
||||
if mode != "conversational" {
|
||||
t.Errorf("mode = %q, want conversational", mode)
|
||||
}
|
||||
if _, ok := inputs["topic"].(map[string]any); !ok {
|
||||
t.Errorf("inputs = %#v, want topic input", inputs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteChatbotRunEvent_UserInputsEvent guards PR #14589: the
|
||||
// SSE envelope must carry the canvas event type so the front-end
|
||||
// can distinguish interactive "user_inputs" / "workflow_finished"
|
||||
|
||||
Reference in New Issue
Block a user