Files
larksuite-cli/cmd/agent/runtime_test.go
liuxinyang.lxy 118ab4bf1e refactor: typed Runtime.Call[T]/CallUpload[T] over raw data JSON
Runtime.CallAPI/CallMultipart now return the response "data" object as
json.RawMessage instead of map[string]any, and provider hooks decode it through
the generic Call[T] / CallUpload[T] helpers: a hook declares the response struct
it expects and the framework unmarshals + classifies errors, instead of poking
at a map. Call[map[string]any] remains available for genuinely dynamic shapes; a
response with no "data" (e.g. a pure write) yields T's zero value and a nil error.

decodeData centralizes the unmarshal + invalid_response classification. cmdRuntime
re-encodes the unwrapped "data" sub-object to raw JSON after CheckResponse. Test
doubles (cmd/agent + example fakeRuntime, card_test fakeRT) updated to the raw
signature; the runtime tests keep the raw-data assertion and add a Call[T]
decode assertion.
2026-07-13 14:08:42 +08:00

179 lines
6.6 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agent
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
iagent "github.com/larksuite/cli/internal/agent"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// staticTokenResolver always returns a fixed token without any HTTP call.
type staticTokenResolver struct{}
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "test-token"}, nil
}
// stubRoundTripper intercepts every outgoing request with a canned response.
type stubRoundTripper struct {
respond func(*http.Request) (*http.Response, error)
}
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
// newTestCmdRuntime builds a cmdRuntime whose client routes every request through
// rt (mirrors cmd/event/runtime_test.go's consumeRuntime harness). Identity is
// pinned to as; agentID is fixed.
func newTestCmdRuntime(rt http.RoundTripper, as core.Identity, agentID string) *cmdRuntime {
sdk := lark.NewClient("test-app", "test-secret",
lark.WithEnableTokenCache(false),
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(&http.Client{Transport: rt}),
)
return &cmdRuntime{
client: &client.APIClient{
SDK: sdk,
ErrOut: io.Discard,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
},
as: as,
agentID: agentID,
}
}
func jsonResponse(status int, body string) func(*http.Request) (*http.Response, error) {
return func(r *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
// TestCmdRuntime_IdentityAndAgentID pins invariant #4: the resolved identity is
// surfaced only via IsBot() (never the raw client), and AgentID echoes the
// addressed agent.
func TestCmdRuntime_IdentityAndAgentID(t *testing.T) {
bot := newTestCmdRuntime(stubRoundTripper{}, core.AsBot, "agt_1")
if !bot.IsBot() {
t.Error("bot runtime should report IsBot()=true")
}
if bot.AgentID() != "agt_1" {
t.Errorf("AgentID should be agt_1, got %q", bot.AgentID())
}
usr := newTestCmdRuntime(stubRoundTripper{}, core.AsUser, "agt_2")
if usr.IsBot() {
t.Error("user runtime should report IsBot()=false")
}
}
// TestCmdRuntime_CallAPI_UnwrapsData pins do(): a 200 OAPI envelope with code=0
// returns the raw "data" object (not the whole envelope), and the typed Call[T]
// helper decodes that raw data into a struct.
func TestCmdRuntime_CallAPI_UnwrapsData(t *testing.T) {
rt := stubRoundTripper{respond: jsonResponse(200, `{"code":0,"msg":"ok","data":{"task_id":"t1","state":"completed"}}`)}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/example/v1/tasks/t1", nil, nil)
if err != nil {
t.Fatalf("CallAPI should succeed: %v", err)
}
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("CallAPI should return the raw data object as valid JSON: %v", err)
}
if data["task_id"] != "t1" || data["state"] != "completed" {
t.Errorf("CallAPI should return the unwrapped data object, got %+v", data)
}
// The typed Call[T] helper decodes that same raw data into a struct — no
// map[string]any assertions at the call site.
got, err := iagent.Call[struct {
TaskID string `json:"task_id"`
State string `json:"state"`
}](context.Background(), r, "GET", "/open-apis/example/v1/tasks/t1", nil, nil)
if err != nil {
t.Fatalf("Call[T] should succeed: %v", err)
}
if got.TaskID != "t1" || got.State != "completed" {
t.Errorf("Call[T] should decode data into the struct, got %+v", got)
}
}
// TestCmdRuntime_CallAPI_APIError pins that a non-zero code becomes a typed error
// (CheckResponse), not a silent success.
func TestCmdRuntime_CallAPI_APIError(t *testing.T) {
rt := stubRoundTripper{respond: jsonResponse(200, `{"code":1254043,"msg":"task not found"}`)}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
if _, err := r.CallAPI(context.Background(), "GET", "/open-apis/example/v1/tasks/nope", nil, nil); err == nil {
t.Fatal("a non-zero API code should surface as an error")
} else if _, ok := errs.ProblemOf(err); !ok {
t.Fatalf("API error should be a typed errs error, got %T: %v", err, err)
}
}
// TestCmdRuntime_CallAPI_TransportError pins the transport-error branch: a
// RoundTrip failure is classified as a network transport error.
func TestCmdRuntime_CallAPI_TransportError(t *testing.T) {
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial refused")
}}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
_, err := r.CallAPI(context.Background(), "POST", "/open-apis/example/v1/messages", nil, map[string]any{"text": "hi"})
if err == nil {
t.Fatal("a transport error should propagate")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryNetwork {
t.Fatalf("transport error should be a network error, got %+v", p)
}
}
// TestCmdRuntime_CallMultipart_RejectsUnsafePath pins invariant #5: CallMultipart
// SafeInputPath-validates every --file BEFORE opening it, so an absolute /
// traversal path is rejected as invalid_argument (param --file) and NO request
// is issued (the transport panics if reached).
func TestCmdRuntime_CallMultipart_RejectsUnsafePath(t *testing.T) {
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
t.Fatal("no request should be issued when the --file path is unsafe")
return nil, nil
}}
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
for _, bad := range []string{"/etc/hosts", "../../etc/passwd"} {
_, err := r.CallMultipart(context.Background(), "POST", "/open-apis/example/v1/attachments",
map[string]string{"type": "file"},
[]iagent.FilePart{{Field: "file", Path: bad}})
if err == nil {
t.Fatalf("an unsafe --file path %q should be rejected", bad)
}
if !errs.IsValidation(err) {
t.Fatalf("unsafe path %q should be a validation error, got %T: %v", bad, err, err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--file" {
t.Errorf("unsafe path %q should carry param --file, got %+v", bad, ve)
}
}
}