diff --git a/shortcuts/wiki/wiki_node_get.go b/shortcuts/wiki/wiki_node_get.go index 62ccdd54d6..1825acf173 100644 --- a/shortcuts/wiki/wiki_node_get.go +++ b/shortcuts/wiki/wiki_node_get.go @@ -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 } diff --git a/shortcuts/wiki/wiki_node_get_test.go b/shortcuts/wiki/wiki_node_get_test.go index 7df66db08e..f94c3293ce 100644 --- a/shortcuts/wiki/wiki_node_get_test.go +++ b/shortcuts/wiki/wiki_node_get_test.go @@ -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) @@ -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) } @@ -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) + } + }) + } +} + +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") + } + 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() @@ -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) } @@ -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) } } @@ -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) @@ -358,7 +447,7 @@ 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() @@ -366,8 +455,8 @@ func TestWikiNodeGetMountedAcceptsNodeTokenFlag(t *testing.T) { 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) @@ -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() @@ -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 { @@ -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) diff --git a/shortcuts/wiki/wiki_token_validation.go b/shortcuts/wiki/wiki_token_validation.go new file mode 100644 index 0000000000..43b4e174c3 --- /dev/null +++ b/shortcuts/wiki/wiki_token_validation.go @@ -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 + +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.", + ) +} diff --git a/tests/cli_e2e/wiki/wiki_node_get_dryrun_test.go b/tests/cli_e2e/wiki/wiki_node_get_dryrun_test.go new file mode 100644 index 0000000000..23d2f3d2de --- /dev/null +++ b/tests/cli_e2e/wiki/wiki_node_get_dryrun_test.go @@ -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) +}