Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions shortcuts/wiki/wiki_node_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
if err := validateOptionalResourceName(spec.Token, "--node-token"); err != nil {
return wikiNodeGetSpec{}, err
}
if err := validateWikiResourceTokenLength(spec.Token, "--node-token"); err != nil {
return wikiNodeGetSpec{}, err
}
if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil {
return wikiNodeGetSpec{}, err
}
Expand Down
159 changes: 124 additions & 35 deletions shortcuts/wiki/wiki_node_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,50 @@ package wiki
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"testing"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)

const (
testWikiNodeToken = "Abcdw_EXAMPLE_WIKI_TOKEN_27"
testDocxObjToken = "Abcdd_EXAMPLE_DOCX_TOKEN_27"
testBaseObjToken = "Abcdb_EXAMPLE_BASE_TOKEN_27"
testSheetObjToken = "Abcds_EXAMPLE_SHT_TOKEN_027"
)

func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("wikcnABC", "", "")
spec, err := parseWikiNodeGetSpec(testWikiNodeToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node wikcnABC with no obj_type", spec)
if spec.Token != testWikiNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, testWikiNodeToken)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC}", got)
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": testWikiNodeToken}) {
t.Fatalf("RequestParams() = %v, want {token: %s}", got, testWikiNodeToken)
}
}

func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
t.Parallel()

const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
// Opaque tokens must not require a known resource-type prefix. Use a
// complete-length fixture so this test remains independent of the
// truncated-token validation contract.
const opaqueNodeToken = "Sm78_EXAMPLE_OPAQUE_TOKEN_X"
if len(opaqueNodeToken) != minWikiResourceTokenLength {
t.Fatalf("opaqueNodeToken length = %d, want %d", len(opaqueNodeToken), minWikiResourceTokenLength)
}
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
Expand All @@ -50,70 +65,70 @@ func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("docxXYZ", "docx", "")
spec, err := parseWikiNodeGetSpec(testDocxObjToken, "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj docxXYZ obj_type=docx", spec)
if spec.Token != testDocxObjToken || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj %s obj_type=docx", spec, testDocxObjToken)
}
}

func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
spec, err := parseWikiNodeGetSpec(testBaseObjToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
if spec.Token != testBaseObjToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, testBaseObjToken)
}
}

func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
spec, err := parseWikiNodeGetSpec(testWikiNodeToken, "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
if spec.Token != testWikiNodeToken || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj %s with obj_type docx", spec, testWikiNodeToken)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": testWikiNodeToken, "obj_type": "docx"}) {
t.Fatalf("RequestParams() = %v, want {token: %s, obj_type: docx}", got, testWikiNodeToken)
}
}

func TestParseWikiNodeGetSpecExtractsTokenFromWikiURL(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/wikcnABC?foo=bar", "", "")
spec, err := parseWikiNodeGetSpec("https://feishu.cn/wiki/"+testWikiNodeToken+"?foo=bar", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
t.Fatalf("spec = %+v, want url-wiki wikcnABC", spec)
if spec.Token != testWikiNodeToken || spec.ObjType != "" || spec.SourceKind != "url-wiki" {
t.Fatalf("spec = %+v, want url-wiki %s", spec, testWikiNodeToken)
}
}

func TestParseWikiNodeGetSpecExtractsTokenAndObjTypeFromDocxURL(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/"+testDocxObjToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "docxXYZ" || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
t.Fatalf("spec = %+v, want url-obj docxXYZ", spec)
if spec.Token != testDocxObjToken || spec.ObjType != "docx" || spec.SourceKind != "url-obj" {
t.Fatalf("spec = %+v, want url-obj %s", spec, testDocxObjToken)
}
}

func TestParseWikiNodeGetSpecRejectsURLObjTypeMismatch(t *testing.T) {
t.Parallel()

_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/shtXYZ", "docx", "")
_, err := parseWikiNodeGetSpec("https://feishu.cn/sheets/"+testSheetObjToken, "docx", "")
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
t.Fatalf("expected URL/obj-type mismatch error, got %v", err)
}
Expand Down Expand Up @@ -145,6 +160,80 @@ func TestParseWikiNodeGetSpecRejectsEmptyToken(t *testing.T) {
}
}

func TestParseWikiNodeGetSpecRejectsTruncatedToken(t *testing.T) {
t.Parallel()

for _, input := range []string{
"PImXw",
"https://feishu.cn/wiki/PImXw",
} {
t.Run(input, func(t *testing.T) {
t.Parallel()

_, err := parseWikiNodeGetSpec(input, "", "")
if err == nil {
t.Fatal("expected truncated token validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--node-token" {
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--node-token", p, validationErr.Param)
}
if !strings.Contains(p.Hint, "complete token") || !strings.Contains(p.Hint, "full Lark URL") {
t.Fatalf("hint = %q, want actionable complete-token guidance", p.Hint)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestValidateWikiResourceTokenLengthBoundary(t *testing.T) {
t.Parallel()

if err := validateWikiResourceTokenLength(strings.Repeat("a", minWikiResourceTokenLength-1), "--node-token"); err == nil {
t.Fatal("expected a 26-character token to be rejected")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err := validateWikiResourceTokenLength(strings.Repeat("a", minWikiResourceTokenLength), "--node-token"); err != nil {
t.Fatalf("expected a 27-character token to be accepted, got %v", err)
}
}

func TestWikiNodeGetRejectsTruncatedTokenBeforeHTTPRequest(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
requested := false
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Optional: true,
OnMatch: func(*http.Request) {
requested = true
},
})

err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--node-token", "PImXw",
"--as", "bot",
}, factory, stdout)
if err == nil {
t.Fatal("expected truncated token validation error")
}
if requested {
t.Fatal("truncated token reached the OpenAPI transport")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %#v ok=%v, want validation/invalid_argument", p, ok)
}
}

func TestResolveWikiNodeGetRawTokenPrefersNodeToken(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -198,7 +287,7 @@ func TestResolveWikiNodeGetRawTokenEmptyDefersToParser(t *testing.T) {
func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
t.Parallel()

spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/docxXYZ", "", "")
spec, err := parseWikiNodeGetSpec("https://feishu.cn/docx/"+testDocxObjToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
Expand All @@ -221,7 +310,7 @@ func TestBuildWikiNodeGetDryRunSendsObjType(t *testing.T) {
if len(got.API) != 1 || got.API[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("dry-run api = %#v, want single get_node call", got.API)
}
if got.API[0].Params["token"] != "docxXYZ" || got.API[0].Params["obj_type"] != "docx" {
if got.API[0].Params["token"] != testDocxObjToken || got.API[0].Params["obj_type"] != "docx" {
t.Fatalf("dry-run params = %#v", got.API[0].Params)
}
}
Expand Down Expand Up @@ -279,15 +368,15 @@ func TestWikiNodeGetMountedExecuteParsesURLAndFormatsOutput(t *testing.T) {

err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--node-token", "https://feishu.cn/docx/" + testDocxObjToken,
"--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}

if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
if !strings.Contains(capturedQuery, "token="+testDocxObjToken) || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=%s and obj_type=docx", capturedQuery, testDocxObjToken)
}

data := decodeWikiEnvelope(t, stdout)
Expand Down Expand Up @@ -358,16 +447,16 @@ func TestWikiNodeGetMountedAcceptsNodeTokenFlag(t *testing.T) {
parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut)
parent.SetArgs([]string{
"+node-get",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--node-token", "https://feishu.cn/docx/" + testDocxObjToken,
"--as", "bot",
})
stdout.Reset()
if err := parent.Execute(); err != nil {
t.Fatalf("parent.Execute() error = %v", err)
}

if !strings.Contains(capturedQuery, "token=docxXYZ") || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=docxXYZ and obj_type=docx", capturedQuery)
if !strings.Contains(capturedQuery, "token="+testDocxObjToken) || !strings.Contains(capturedQuery, "obj_type=docx") {
t.Fatalf("captured query = %q, want token=%s and obj_type=docx", capturedQuery, testDocxObjToken)
}

data := decodeWikiEnvelope(t, stdout)
Expand Down Expand Up @@ -428,7 +517,7 @@ func TestWikiNodeGetMountedLegacyTokenFlagWarnsButWorks(t *testing.T) {
parent := mountWikiNodeGetWithFlagOut(t, factory, &flagOut)
parent.SetArgs([]string{
"+node-get",
"--token", "wikcnABC",
"--token", testWikiNodeToken,
"--as", "bot",
})
stdout.Reset()
Expand Down Expand Up @@ -491,7 +580,7 @@ func TestWikiNodeGetFallsBackToCreatorWhenNodeCreatorMissing(t *testing.T) {

err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--node-token", "wikcnABC",
"--node-token", testWikiNodeToken,
"--as", "bot",
}, factory, stdout)
if err != nil {
Expand Down Expand Up @@ -530,7 +619,7 @@ func TestWikiNodeGetRejectsSpaceIDMismatch(t *testing.T) {

err := mountAndRunWiki(t, WikiNodeGet, []string{
"+node-get",
"--node-token", "wikcnABC",
"--node-token", testWikiNodeToken,
"--space-id", "space_expected",
"--as", "bot",
}, factory, stdout)
Expand Down
23 changes: 23 additions & 0 deletions shortcuts/wiki/wiki_token_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package wiki

import "github.com/larksuite/cli/errs"

// minWikiResourceTokenLength is the minimum length of a complete Lark
// resource token accepted by Wiki node lookup.
const minWikiResourceTokenLength = 27
Comment thread
liujinkun2025 marked this conversation as resolved.

func validateWikiResourceTokenLength(token, flagName string) error {
if len(token) >= minWikiResourceTokenLength {
return nil
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"%s is too short to be a complete Lark resource token",
flagName,
).WithParam(flagName).WithHint(
"Pass the complete token or a full Lark URL; do not pass a truncated, masked, or placeholder value.",
)
}
40 changes: 40 additions & 0 deletions tests/cli_e2e/wiki/wiki_node_get_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package wiki

import (
"context"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestWikiNodeGetDryRunRejectsTruncatedToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "wiki_node_get_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "wiki_node_get_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)

result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-get",
"--node-token", "PImXw",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--node-token", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "complete token", result.Stderr)
}
Loading