mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
* feat: add strict mode identity filter, profile management and credential extension Port changes from feat/strict-mode-identity-filter_3 branch: - Add strict mode for identity filtering and configuration - Add profile management commands (add/list/remove/rename/use) - Add credential extension framework (registry, env provider) - Add VFS abstraction layer - Refactor factory default and client options - Update shortcuts to use new credential and validation patterns Change-Id: I8c104c6b147e1901d94aefcefe35a174932c742b Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: go mod tidy Change-Id: I0f610ccea6bc874248e84c24770944a3071dcc57 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix test failures from credential provider migration - Remove unused TAT stub registrations in api and service tests (CredentialProvider manages tokens, SDK no longer calls TAT endpoint) - Update strict mode integration test: +chat-create now supports user identity, so it should succeed under strict mode user Change-Id: Iab51c2e12a97995e0b95dcd71df212d2d1f76570 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: migrate remaining os calls to internal/vfs Replace direct os.Stat/Open/MkdirAll/OpenFile/Remove/ReadDir/UserHomeDir with vfs equivalents in shortcuts/minutes, shortcuts/drive, and internal/keychain. Add ReadDir to the vfs interface and OsFs implementation. Change-Id: I8f97e5fb3e1731b4684d276644fcb10fae823067 * fix: resolve gofmt and goimports formatting issues Change-Id: If61578631f5698f7ca2d9a946ca59753651463fb * feat: add Flag.Input support for @file and stdin input sources Add framework-level support for reading flag values from files (@path) or stdin (-), solving the fundamental problem of passing complex text (markdown, multi-line content) via CLI arguments where shell escaping breaks content. Closes #239, fixes #163. - Add File/Stdin constants and Input field to Flag struct - Add resolveInputFlags() in runner pipeline (pre-Validate) - Support @@ escape for literal @ prefix - Guard against multiple stdin consumers - Auto-append "(supports @file, - for stdin)" to help text - Apply to: docs +create/+update --markdown, im +messages-send/+reply --text/--markdown/--content, task +comment --content, drive +add-comment --content Change-Id: I305a326d972417542aeadd70f37b74ea456461ef Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix pre-existing test failures in task, minutes, and registry - task/minutes: remove unused tenant_access_token httpmock stubs (TestFactory's testDefaultToken provides tokens directly, so the HTTP stub was never consumed and failed verification) - registry: fix hasEmbeddedData() to check for actual services instead of just byte length (meta_data_default.json has empty services array) Change-Id: Ic7b5fc7f9de09137a7254fe1ddf47d24ade40587 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: suppress nilerr lint for intentional nil returns Both cases intentionally return nil on error for graceful degradation: - profile list: show friendly message when config is not initialized - service: skip scope check when token resolution fails Change-Id: I7285c37277c9b0361a421ab00359244c2cd150b3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review feedback - runner.go: fail fast when Input is used on non-string flags - remote_test.go: rename hasEmbeddedData → hasEmbeddedServices - profile/list.go: add omitempty to optional JSON fields - service.go: surface context cancellation errors in scope check Change-Id: I7072d41f8c711b4b37c542e32dfd8150f42b13c0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: tighten credential resolution and profile flows Change-Id: I83f6d424540eab9b1708944b9b6e26e8477cc60d * refactor: centralize identity hint resolution Change-Id: I38d5f98160b92adb62dc929ae73697ae5b3d64f8 * fix: surface unverified extension identities Change-Id: Ia86d9bd19add9010176339ec4cc89deb033f5b4f * fix: honor runtime credential sources in config views Change-Id: I40b2ffedc5c1db5e08e86b9472ea2b84fa02bb29 * fix: prefer runtime values in config show commands Change-Id: I5663a53e147577f0f1f533f67d12bea504e6b839 * Revert "fix: prefer runtime values in config show commands" This reverts commit4f9db3a227. * Revert "fix: honor runtime credential sources in config views" This reverts commitb3bfd526c5. * fix: harden profile flows and credential boundaries Change-Id: Ica61cd2730a639f71516cb1b237a639cb6511f7a * fix: optimize profile and config inspection for agents Change-Id: I19c368102f19654952638180ab947788a6971563 * refactor: unify credential env contracts Change-Id: I0ff2c0a650ea53589a0626333e8f6e628ef10a54 * docs: expand AGENTS guidance Change-Id: I289027dfd364c92205012feef6f05037066c035b * fix: resolve regression bugs found during PR #252 review - im: fix double SafeInputPath in resolveLocalMedia → uploadImageToIM/ uploadFileToIM chain that rejected all local image/file uploads - credential: stop writing plain-text warnings to stderr, preserving JSON envelope contract for AI agent consumers - profile add: reject duplicate app-id to prevent keychain credential collisions across profiles - profile rename: exclude self when checking name uniqueness so renaming to own appId works correctly - config: replace bare fmt.Errorf with output.Errorf in save-failure paths (default_as, strict_mode ×2, profile add) - factory: remove unused resolveDefaultAs method (lint) Change-Id: I6aa0d064414016f367f1edb08dd0604adf7bf13d Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove flaky TestColdStart_UsesEmbedded (race in registry) The test triggers a data race: resetInit() writes package globals while a background goroutine from a previous test may still be reading them. The embedded-data path is covered by other tests. Change-Id: I7a0c3bf85a9fb337b9279c9053697f40a0c0a0d4 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: type-strengthen Brand and DefaultAs across credential chain Replace raw string fields with typed enums for compile-time safety: - extension/credential: add Brand and Identity named types - internal/core: AppConfig.DefaultAs and CliConfig.DefaultAs → Identity - internal/credential: Account.DefaultAs and IdentityHint.DefaultAs → core.Identity The full data flow is now typed end-to-end: extcred.Brand → core.LarkBrand (named-type cast) extcred.Identity → core.Identity (named-type cast) No string intermediaries, no implicit conversions. Change-Id: I715b3b3f033fcb624010f1af9619e3562740ef08 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix gofmt alignment in extension/credential/types.go Change-Id: Ibfac0703a5a28f3c6ba4a47bf40696028d0f3b90 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove file/stdin input support from task comment content flag Change-Id: If49704ca4612465a23bd30b755d6e72a35fc2349 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cmdutil): remove dead code autoDetectIdentity autoDetectIdentity() is only called from tests, never from production code. Remove it along with its 3 test cases to reduce surface area before the upcoming ctx propagation refactor. Change-Id: I35a188860f17656f3e1fe9874f87f284985ae196 * refactor(cmdutil): add ctx parameter to resolveIdentityHint Private method resolveIdentityHint now accepts context.Context and passes it to CredentialProvider.ResolveIdentityHint instead of using context.Background(). The caller (ResolveAs) still uses context.Background() temporarily until its own signature is updated. Change-Id: I14634a4e0dc1d657d56936ba61a7b7a206da8ac4 * refactor(cmdutil): add ctx parameter to ResolveStrictMode ResolveStrictMode now accepts context.Context and passes it to CredentialProvider.ResolveAccount instead of using context.Background(). Callers in cobra RunE pass cmd.Context(); callers outside RunE (cmd/root.go startup, tests) use context.Background() explicitly. Change-Id: I31be48e548ac5ac5640a65f3bfdde4a53ed1dc7e * refactor(cmdutil): add ctx parameter to CheckStrictMode CheckStrictMode now accepts context.Context and forwards it to ResolveStrictMode. Callers pass cmd.Context() (cobra RunE) or opts.Ctx (APIOptions/ServiceMethodOptions). Change-Id: I47888519d4cae8c94054771c32aff075565a8cdc * refactor(cmdutil): add ctx parameter to ResolveAs ResolveAs now accepts context.Context as first parameter and forwards it to ResolveStrictMode and resolveIdentityHint. This completes the ctx propagation chain: all Factory methods that call CredentialProvider now receive ctx from cobra cmd.Context(). No more context.Background() calls remain in factory.go for credential provider operations. Change-Id: I6d10b6350e3b149470660de3e7855614314e8b29 * test: fix gofmt in cmdutil factory tests Change-Id: I4a87d5a815b959f14cc4371b73dee4aae106932f * fix: remove file/stdin input support from im send/reply and drive comment The Input (file/stdin) feature is not yet ready for these flags: - im send/reply: --content, --text, --markdown - drive add-comment: --content Retained only in doc create/update where markdown from file is essential. Change-Id: I582b6349528fccb639ad9edc84650cca3b68535c Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: liushiyao <liushiyao.1206@bytedance.com>
656 lines
19 KiB
Go
656 lines
19 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package api
|
|
|
|
import (
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Method != "GET" {
|
|
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
|
}
|
|
if gotOpts.Path != "/open-apis/test" {
|
|
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
|
|
}
|
|
if gotOpts.As != core.AsBot {
|
|
t.Errorf("expected as=bot, got %s", gotOpts.As)
|
|
}
|
|
if !gotOpts.DryRun {
|
|
t.Error("expected DryRun=true")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_DryRun(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "Dry Run") {
|
|
t.Error("expected dry run output")
|
|
}
|
|
if !strings.Contains(output, "/open-apis/test") {
|
|
t.Error("expected path in dry run output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_BotMode(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Register API endpoint stub
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "success") {
|
|
t.Error("expected 'success' in output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_MissingArgs(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET"}) // missing path
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Error("expected error for missing args")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Error("expected validation error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestApiValidArgsFunction(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
fn := cmd.ValidArgsFunction
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
toComplete string
|
|
wantComps []string
|
|
wantDir cobra.ShellCompDirective
|
|
}{
|
|
{
|
|
name: "no args returns HTTP methods",
|
|
args: []string{},
|
|
toComplete: "",
|
|
wantComps: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
|
|
wantDir: cobra.ShellCompDirectiveNoFileComp,
|
|
},
|
|
{
|
|
name: "one arg returns nil with NoFileComp",
|
|
args: []string{"GET"},
|
|
toComplete: "",
|
|
wantComps: nil,
|
|
wantDir: cobra.ShellCompDirectiveNoFileComp,
|
|
},
|
|
{
|
|
name: "two args returns nil with NoFileComp",
|
|
args: []string{"GET", "/path"},
|
|
toComplete: "",
|
|
wantComps: nil,
|
|
wantDir: cobra.ShellCompDirectiveNoFileComp,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
comps, dir := fn(cmd, tt.args, tt.toComplete)
|
|
if dir != tt.wantDir {
|
|
t.Errorf("directive = %d, want %d", dir, tt.wantDir)
|
|
}
|
|
if tt.wantComps == nil {
|
|
if comps != nil {
|
|
t.Errorf("completions = %v, want nil", comps)
|
|
}
|
|
return
|
|
}
|
|
sort.Strings(comps)
|
|
sort.Strings(tt.wantComps)
|
|
if len(comps) != len(tt.wantComps) {
|
|
t.Errorf("completions = %v, want %v", comps, tt.wantComps)
|
|
return
|
|
}
|
|
for i := range comps {
|
|
if comps[i] != tt.wantComps[i] {
|
|
t.Errorf("completions = %v, want %v", comps, tt.wantComps)
|
|
break
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.PageLimit != 10 {
|
|
t.Errorf("expected default PageLimit=10, got %d", gotOpts.PageLimit)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--page-all", "--output", "file.bin"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --output + --page-all conflict")
|
|
}
|
|
if gotOpts != nil && !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/drive/v1/files/xxx/download",
|
|
RawBody: []byte("fake-binary-content"),
|
|
ContentType: "application/octet-stream",
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "binary response detected") {
|
|
t.Error("expected binary response hint in stderr")
|
|
}
|
|
if !strings.Contains(stdout.String(), "saved_path") {
|
|
t.Error("expected saved_path in output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Register a non-batch API that returns scalar data (no array field)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/contact/v3/users/u123",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"user_id": "u123",
|
|
"name": "Test User",
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should print fallback warning to stderr
|
|
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
|
|
t.Error("expected fallback warning in stderr")
|
|
}
|
|
if !strings.Contains(stderr.String(), "falling back to json") {
|
|
t.Error("expected 'falling back to json' in stderr")
|
|
}
|
|
// Should output JSON result to stdout
|
|
if !strings.Contains(stdout.String(), "u123") {
|
|
t.Error("expected user_id in JSON output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Non-batch API that returns a business error (code != 0)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
|
Body: map[string]interface{}{
|
|
"code": 230001, "msg": "no permission",
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
|
err := cmd.Execute()
|
|
// Should return an error
|
|
if err == nil {
|
|
t.Fatal("expected an error for non-zero code")
|
|
}
|
|
// Should still output the response body so user can see the error details
|
|
if !strings.Contains(stdout.String(), "230001") {
|
|
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "no permission") {
|
|
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Register a batch API that returns an array field
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/contact/v3/users",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should NOT print fallback warning
|
|
if strings.Contains(stderr.String(), "warning: this API does not return a list") {
|
|
t.Error("expected no fallback warning for batch API")
|
|
}
|
|
// Should stream ndjson items
|
|
if !strings.Contains(stdout.String(), `"id"`) {
|
|
t.Error("expected streamed items in output")
|
|
}
|
|
}
|
|
|
|
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{"plain path", "/open-apis/test", "/open-apis/test"},
|
|
{"with query", "/open-apis/test?admin=true", "/open-apis/test"},
|
|
{"with fragment", "/open-apis/test#section", "/open-apis/test"},
|
|
{"with both", "/open-apis/test?a=1#frag", "/open-apis/test"},
|
|
{"full URL with query", "https://open.feishu.cn/open-apis/foo?bar=1", "/open-apis/foo"},
|
|
{"short path with query", "contact/v3/users?page_size=50", "/open-apis/contact/v3/users"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalisePath(tt.raw)
|
|
if got != tt.want {
|
|
t.Errorf("normalisePath(%q) = %q, want %q", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_APIError_IsRaw(t *testing.T) {
|
|
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Return a permission error from the API
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/perm",
|
|
Body: map[string]interface{}{
|
|
"code": 99991672,
|
|
"msg": "scope not enabled for this app",
|
|
"error": map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "calendar:calendar:readonly"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for permission denied API response")
|
|
}
|
|
|
|
// Error should be marked Raw
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
|
}
|
|
if !exitErr.Raw {
|
|
t.Error("expected API error from api command to be marked Raw")
|
|
}
|
|
|
|
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
|
|
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
|
|
_ = stderr
|
|
}
|
|
|
|
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/origmsg",
|
|
Body: map[string]interface{}{
|
|
"code": 99991672,
|
|
"msg": "scope not enabled for this app",
|
|
"error": map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "im:message:readonly"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
|
}
|
|
// The message should NOT have been enriched (no "App scope not enabled" replacement)
|
|
if strings.Contains(exitErr.Error(), "App scope not enabled") {
|
|
t.Error("expected original message, not enriched message")
|
|
}
|
|
// Detail should still contain the raw API error detail
|
|
if exitErr.Detail == nil {
|
|
t.Fatal("expected non-nil Detail")
|
|
}
|
|
if exitErr.Detail.Detail == nil {
|
|
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/rawpage",
|
|
Body: map[string]interface{}{
|
|
"code": 99991672,
|
|
"msg": "scope not enabled",
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) {
|
|
t.Fatalf("expected *output.ExitError, got %T", err)
|
|
}
|
|
if !exitErr.Raw {
|
|
t.Error("expected paginated API error to be marked Raw")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.JqExpr != ".data" {
|
|
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.JqExpr != ".data" {
|
|
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --jq + --output conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/jq",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"name": "Alice"},
|
|
map[string]interface{}{"name": "Bob"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
|
t.Errorf("expected jq-filtered names, got: %s", out)
|
|
}
|
|
// Should NOT contain the full envelope structure
|
|
if strings.Contains(out, `"code"`) {
|
|
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --jq + --format ndjson conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid jq expression")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid jq expression") {
|
|
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/contact/v3/users",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
|
|
t.Errorf("expected jq-filtered ids, got: %s", out)
|
|
}
|
|
if strings.Contains(out, `"code"`) {
|
|
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_MethodUppercase(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"post", "/test"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Method != "POST" {
|
|
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
|
|
}
|
|
}
|