Files
larksuite__cli/shortcuts/drive/drive_update_title_test.go

1300 lines
44 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDriveUpdateTitleInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantToken string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
urlInput: "https://example.larksuite.com/docx/docxRenameTarget?from=share",
wantToken: "docxRenameTarget",
wantType: "docx",
},
{
name: "url folder",
urlInput: "https://example.larksuite.com/drive/folder/folderRenameTarget",
wantToken: "folderRenameTarget",
wantType: "folder",
},
{
name: "url wiki keeps the node token",
urlInput: "https://example.larksuite.com/wiki/wikiRenameTarget",
wantToken: "wikiRenameTarget",
wantType: "wiki",
},
{
name: "url base normalizes to bitable",
urlInput: "https://example.larksuite.com/base/bitableRenameTarget",
wantToken: "bitableRenameTarget",
wantType: "bitable",
},
{
name: "token flag also accepts url",
rawInput: "https://example.larksuite.com/sheets/sheetRenameTarget",
wantToken: "sheetRenameTarget",
wantType: "sheet",
},
{
name: "bare token with type",
rawInput: "fileRenameTarget",
docType: "file",
wantToken: "fileRenameTarget",
wantType: "file",
},
{
name: "bare token with base alias",
rawInput: "bitableRenameTarget",
docType: "BASE",
wantToken: "bitableRenameTarget",
wantType: "bitable",
},
{
name: "bare token with wiki type",
rawInput: "wikiRenameTarget",
docType: "wiki",
wantToken: "wikiRenameTarget",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxRenameTarget",
rawInput: "docxRenameTarget",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "missing input",
wantErr: "specify --url or --token",
wantParam: "--url",
},
{
name: "bare token needs type",
rawInput: "docxRenameTarget",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
urlInput: "https://example.larksuite.com/docx/docxRenameTarget",
docType: "sheet",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unrecognized url",
urlInput: "https://example.larksuite.com/unknown/path",
wantErr: "unsupported --url URL",
wantParam: "--url",
},
{
name: "token with path fragments",
rawInput: "token/with/../slash",
docType: "docx",
wantErr: "path traversal",
wantParam: "--token",
},
{
name: "url token with path traversal",
urlInput: "https://example.larksuite.com/docx/..",
wantErr: "path traversal",
wantParam: "--url",
},
{
// minutes is a real Drive resource kind, just not one this endpoint takes.
name: "invalid bare type",
rawInput: "someToken",
docType: "minutes",
wantErr: "invalid --type",
wantParam: "--type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveUpdateTitleInput(tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveUpdateTitleValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantToken || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
}
})
}
}
func TestResolveDriveUpdateTitleInputPreservesTokenValidationCause(t *testing.T) {
t.Parallel()
for _, input := range []struct {
urlInput string
rawInput string
docType string
}{
{urlInput: "https://example.larksuite.com/docx/.."},
{rawInput: "token/../other", docType: "docx"},
} {
_, err := resolveDriveUpdateTitleInput(input.urlInput, input.rawInput, input.docType)
if err == nil {
t.Fatal("expected token validation error")
}
if errors.Unwrap(err) == nil {
t.Fatalf("validation cause was not preserved: %v", err)
}
}
}
// Miaoda apps are the one Drive-shaped resource the endpoint refuses, so both
// spellings redirect to the domain that owns them instead of failing as an
// unrecognized URL or a bare enum rejection.
func TestResolveDriveUpdateTitleInputRedirectsApps(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantParam string
}{
{name: "page url", urlInput: "https://example.feishu.cn/page/pageRenameTarget", wantParam: "--url"},
{name: "page url with trailing slash via token flag", rawInput: "https://example.feishu.cn/page/pageRenameTarget/", wantParam: "--token"},
{name: "bare token with apps type", rawInput: "pageRenameTarget", docType: "apps", wantParam: "--type"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := resolveDriveUpdateTitleInput(tt.urlInput, tt.rawInput, tt.docType)
if err == nil {
t.Fatal("expected an apps redirect error, got nil")
}
assertDriveUpdateTitleValidationError(t, err, tt.wantParam)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(validationErr.Message, "does not support Miaoda apps") {
t.Fatalf("message = %q, want the apps limitation named", validationErr.Message)
}
// The hint stops at the owning domain: apps-domain flags would drift.
if !strings.Contains(validationErr.Hint, "apps domain") || !strings.Contains(validationErr.Hint, "lark-cli apps --help") {
t.Fatalf("hint = %q, want the apps-domain redirect", validationErr.Hint)
}
// The rejected token is never echoed next to a runnable command.
if strings.Contains(validationErr.Message, "pageRenameTarget") || strings.Contains(validationErr.Hint, "pageRenameTarget") {
t.Fatalf("message %q / hint %q must not echo the caller's token", validationErr.Message, validationErr.Hint)
}
})
}
}
// doc and mindnote are listed by the platform metadata but refused by the
// server, so every spelling is turned down locally with the reason.
func TestResolveDriveUpdateTitleInputRejectsServerRefusedTypes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantType string
wantParam string
}{
{name: "bare token with doc type", rawInput: "docRenameTarget", docType: "doc", wantType: "doc", wantParam: "--type"},
{name: "bare token with mindnote type", rawInput: "mindnoteRenameTarget", docType: "mindnote", wantType: "mindnote", wantParam: "--type"},
{name: "doc url", urlInput: "https://example.larksuite.com/doc/docRenameTarget", wantType: "doc", wantParam: "--url"},
{name: "mindnote url", urlInput: "https://example.larksuite.com/mindnote/mindnoteRenameTarget", wantType: "mindnote", wantParam: "--url"},
{name: "mindnote url via token flag", rawInput: "https://example.larksuite.com/mindnote/mindnoteRenameTarget", wantType: "mindnote", wantParam: "--token"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := resolveDriveUpdateTitleInput(tt.urlInput, tt.rawInput, tt.docType)
if err == nil {
t.Fatalf("expected %s to be rejected, got nil", tt.wantType)
}
assertDriveUpdateTitleValidationError(t, err, tt.wantParam)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(validationErr.Message, "type="+tt.wantType) {
t.Fatalf("message = %q, want the refused type named", validationErr.Message)
}
// The server code is part of the message so an agent can match what it
// would have seen had the request gone out.
if !strings.Contains(validationErr.Message, "981002") {
t.Fatalf("message = %q, want the server code named", validationErr.Message)
}
if !strings.Contains(validationErr.Hint, "Supported types here: docx, sheet, bitable, slides, file, folder, wiki") {
t.Fatalf("hint = %q, want the supported set listed", validationErr.Hint)
}
})
}
}
// Tokens are caller-controlled, so no error text may hand back one inside
// something a human or an agent could paste into a shell.
func TestResolveDriveUpdateTitleInputNeverEchoesTokenIntoCommands(t *testing.T) {
t.Parallel()
hostile := []string{
"tok;whoami",
"tok with space",
"tok`id`",
"tok$(id)",
"tok'squote",
"tok\"dquote",
"tok&&ls",
"tok|cat",
}
for _, token := range hostile {
t.Run(token, func(t *testing.T) {
t.Parallel()
// Every spelling that can fail with the token in hand: an unsupported
// type, a server-refused type, and the apps redirect.
for _, docType := range []string{"minutes", "doc", "mindnote", "apps"} {
_, err := resolveDriveUpdateTitleInput("", token, docType)
if err == nil {
t.Fatalf("type %s: expected a rejection for %q", docType, token)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("type %s: expected a typed error, got %T", docType, err)
}
if strings.Contains(problem.Hint, token) {
t.Fatalf("type %s: hint %q must not embed the caller token %q", docType, problem.Hint, token)
}
// Messages may quote the input, but never unquoted next to a command.
if strings.Contains(problem.Message, "lark-cli") && strings.Contains(problem.Message, token) {
t.Fatalf("type %s: message %q pastes the token into a command", docType, problem.Message)
}
}
})
}
}
func TestParseDriveUpdateTitleAppsURL(t *testing.T) {
t.Parallel()
if token, ok := parseDriveUpdateTitleAppsURL("https://example.feishu.cn/page/pageRenameTarget?from=share"); !ok || token != "pageRenameTarget" {
t.Fatalf("parse = (%q, %v), want (pageRenameTarget, true)", token, ok)
}
for _, raw := range []string{
"pageRenameTarget", // bare token
"https://example.feishu.cn/docx/docxRenameTarget", // another resource kind
"https://example.feishu.cn/page/", // no token
"https://example.feishu.cn/page/nested/extra", // not a single segment
"ftp://example.feishu.cn/page/pageRenameTarget", // unsupported scheme
"/page/pageRenameTarget", // no host
} {
if token, ok := parseDriveUpdateTitleAppsURL(raw); ok {
t.Fatalf("parse(%q) = (%q, true), want no match", raw, token)
}
}
}
// The flag enum accepts apps so the redirect fires; the request path never does.
func TestDriveUpdateTitleFlagTypesCarryAppsButAPITypesDoNot(t *testing.T) {
t.Parallel()
var flagHasApps bool
for _, docType := range driveUpdateTitleFlagTypes {
if docType == driveUpdateTitleAppsType {
flagHasApps = true
}
}
if !flagHasApps {
t.Fatalf("driveUpdateTitleFlagTypes = %v, want apps accepted at parse time", driveUpdateTitleFlagTypes)
}
if driveUpdateTitleTypeSupported(driveUpdateTitleAppsType) {
t.Fatal("apps must not be treated as a supported request type")
}
// Same split for the types the server refuses: accepted by the flag so the
// caller gets the reason, never sent as a request type.
flagTypes := strings.Join(driveUpdateTitleFlagTypes, ",")
for _, docType := range driveUpdateTitleRejectedTypes {
if !strings.Contains(flagTypes, docType) {
t.Fatalf("driveUpdateTitleFlagTypes = %v, want %q accepted at parse time", driveUpdateTitleFlagTypes, docType)
}
if driveUpdateTitleTypeSupported(docType) {
t.Fatalf("%q must not be treated as a supported request type", docType)
}
if !driveUpdateTitleTypeRejected(docType) {
t.Fatalf("%q must be recognized as server-refused", docType)
}
}
if driveUpdateTitleTypeRejected("docx") {
t.Fatal("docx must not be marked server-refused")
}
}
func TestValidateDriveUpdateTitleSpec(t *testing.T) {
t.Parallel()
valid := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "docxRenameTarget", Type: "docx", SourceFlag: "--url"},
Title: "New title",
ExtPolicy: driveUpdateTitleExtKeep,
}
if err := validateDriveUpdateTitleSpec(valid, false); err != nil {
t.Fatalf("unexpected error for valid spec: %v", err)
}
empty := valid
empty.Title = ""
err := validateDriveUpdateTitleSpec(empty, false)
if err == nil || !strings.Contains(err.Error(), "--title must not be empty") {
t.Fatalf("expected empty-title error, got %v", err)
}
assertDriveUpdateTitleValidationError(t, err, "--title")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(validationErr.Hint, "clears the resource title") {
t.Fatalf("hint should explain why an empty title is refused locally, got %q", validationErr.Hint)
}
// The extension policy has nothing to guard outside --type file, so passing
// it there is rejected instead of silently ignored.
err = validateDriveUpdateTitleSpec(valid, true)
if err == nil || !strings.Contains(err.Error(), "only applies to --type file") {
t.Fatalf("expected extension-policy scope error, got %v", err)
}
assertDriveUpdateTitleValidationError(t, err, "--on-extension-mismatch")
fileSpec := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "fileRenameTarget", Type: "file", SourceFlag: "--token"},
Title: "report.xlsx",
ExtPolicy: driveUpdateTitleExtAllow,
}
if err := validateDriveUpdateTitleSpec(fileSpec, true); err != nil {
t.Fatalf("unexpected error for --type file with an explicit policy: %v", err)
}
removedPolicy := fileSpec
removedPolicy.ExtPolicy = "error"
err = validateDriveUpdateTitleSpec(removedPolicy, true)
if err == nil {
t.Fatal("expected the removed error policy to be rejected")
}
assertDriveUpdateTitleValidationError(t, err, "--on-extension-mismatch")
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %#v, want validation/invalid_argument", problem)
}
}
func TestDriveUpdateTitleDeclaresOnlySharedDriveScope(t *testing.T) {
t.Parallel()
got := DriveUpdateTitle.ConditionalScopes
if len(got) != 1 || got[0] != "drive:file:upload" {
t.Fatalf("ConditionalScopes = %v, want only drive:file:upload", got)
}
}
func TestDriveUpdateTitleExtensionHelpers(t *testing.T) {
t.Parallel()
tests := map[string]string{
"report.xlsx": ".xlsx",
"report.XLSX": ".XLSX",
" report.md ": ".md",
"report": "",
"report v1.2": ".2",
"archive.tar.gz": ".gz",
"trailing.": ".",
".hidden": "", // leading dot only is a name, not an extension
"dir/report.xlsx": ".xlsx",
}
for title, want := range tests {
if got := driveUpdateTitleExtension(title); got != want {
t.Fatalf("driveUpdateTitleExtension(%q) = %q, want %q", title, got, want)
}
}
}
func TestDriveUpdateTitleSpecNeedsCurrentTitle(t *testing.T) {
t.Parallel()
tests := []struct {
docType string
policy string
want bool
}{
{docType: "file", policy: driveUpdateTitleExtKeep, want: true},
{docType: "file", policy: driveUpdateTitleExtAllow, want: false},
{docType: "docx", policy: driveUpdateTitleExtKeep, want: false},
{docType: "folder", policy: driveUpdateTitleExtKeep, want: false},
}
for _, tt := range tests {
spec := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "tok", Type: tt.docType},
Title: "t",
ExtPolicy: tt.policy,
}
if got := spec.NeedsCurrentTitle(); got != tt.want {
t.Fatalf("type=%s policy=%s NeedsCurrentTitle() = %v, want %v", tt.docType, tt.policy, got, tt.want)
}
}
}
func TestBuildDriveUpdateTitleRequest(t *testing.T) {
t.Parallel()
spec := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "wikiRenameTarget", Type: "wiki", SourceFlag: "--url"},
Title: "Renamed node",
}
if got := buildDriveUpdateTitleParams(spec)["type"]; got != "wiki" {
t.Fatalf("params type = %#v, want wiki", got)
}
if got := buildDriveUpdateTitleBody(spec)["new_title"]; got != "Renamed node" {
t.Fatalf("body new_title = %#v, want Renamed node", got)
}
if got := driveUpdateTitlePath("folder/token"); got != "/open-apis/drive/v1/files/folder%2Ftoken" {
t.Fatalf("path = %q, want the token percent-encoded as a single segment", got)
}
}
func TestDriveUpdateTitleErrorGuidance(t *testing.T) {
t.Parallel()
wikiSpec := driveUpdateTitleSpec{Ref: driveUpdateTitleRef{Token: "wikiRenameTarget", Type: "wiki"}, Title: "t"}
if got := driveUpdateTitleErrorGuidance(981003, wikiSpec); !strings.Contains(got, "wiki node token") {
t.Fatalf("wiki 981003 guidance = %q, want wiki node token guidance", got)
}
docxSpec := driveUpdateTitleSpec{Ref: driveUpdateTitleRef{Token: "docxRenameTarget", Type: "docx"}, Title: "t"}
if got := driveUpdateTitleErrorGuidance(981003, docxSpec); !strings.Contains(got, "--type docx") {
t.Fatalf("docx 981003 guidance = %q, want the declared type echoed", got)
}
if got := driveUpdateTitleErrorGuidance(981004, docxSpec); !strings.Contains(got, "edit permission") {
t.Fatalf("981004 guidance = %q, want edit permission guidance", got)
}
// A document-permission failure must not send the caller hunting for a scope.
if got := driveUpdateTitleErrorGuidance(981004, docxSpec); !strings.Contains(got, "not a missing scope") {
t.Fatalf("981004 guidance = %q, want it separated from the scope codes", got)
}
if got := driveUpdateTitleErrorGuidance(1061003, docxSpec); got != "" {
t.Fatalf("unmapped code guidance = %q, want empty", got)
}
// Rate limit: the platform answers 99991400 for this endpoint, and the fix is
// serializing, not retrying harder.
if got := driveUpdateTitleErrorGuidance(99991400, docxSpec); !strings.Contains(got, "rate limited") ||
!strings.Contains(got, "serialize") || !strings.Contains(got, "backoff") {
t.Fatalf("99991400 guidance = %q, want serialize-and-backoff guidance", got)
}
// Scope: the endpoint's scope set is an any-of keyed by --type, so the
// guidance has to name the one that matches the target.
scopeByType := map[string]string{
"docx": "docx:document:write_only",
"doc": "docx:document:write_only",
"sheet": "sheets:spreadsheet:write_only",
"bitable": "base:app:update",
"file": "drive:file:upload",
"folder": "drive:file:upload",
}
for docType, wantScope := range scopeByType {
spec := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "tok", Type: docType},
Title: "t.xlsx",
ExtPolicy: driveUpdateTitleExtAllow, // keeps the guard note out of the way
}
for _, code := range []int{99991672, 99991679} {
got := driveUpdateTitleErrorGuidance(code, spec)
if !strings.Contains(got, wantScope) {
t.Fatalf("%d guidance for --type %s = %q, want %s named", code, docType, got, wantScope)
}
}
}
// Types the platform metadata does not map to a single scope get the whole
// any-of set instead of a guess.
wikiScopeSpec := driveUpdateTitleSpec{Ref: driveUpdateTitleRef{Token: "tok", Type: "wiki"}, Title: "t"}
if got := driveUpdateTitleErrorGuidance(99991672, wikiScopeSpec); !strings.Contains(got, "one of docx:document:write_only") {
t.Fatalf("wiki scope guidance = %q, want the any-of set", got)
}
// When the extension guard is on, its own read scope is part of the fix.
guardedSpec := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "tok", Type: "file"},
Title: "t",
ExtPolicy: driveUpdateTitleExtKeep,
}
got := driveUpdateTitleErrorGuidance(99991679, guardedSpec)
if !strings.Contains(got, "drive:drive.metadata:readonly") || !strings.Contains(got, "--on-extension-mismatch=allow") {
t.Fatalf("guarded scope guidance = %q, want the metadata scope and the allow escape", got)
}
if err := decorateDriveUpdateTitleError(nil, docxSpec); err != nil {
t.Fatalf("decorating nil = %v, want nil", err)
}
plain := errors.New("not typed")
if got := decorateDriveUpdateTitleError(plain, docxSpec); got != plain {
t.Fatalf("untyped error should pass through unchanged, got %v", got)
}
// A hint the lower layer already produced is kept, with command guidance
// appended once rather than replacing it.
existing := errs.NewAPIError(errs.SubtypeNotFound, "not found.").WithCode(981003).WithHint("server hint")
decorated := decorateDriveUpdateTitleError(existing, docxSpec)
problem, ok := errs.ProblemOf(decorated)
if !ok {
t.Fatalf("expected typed error, got %T", decorated)
}
if !strings.HasPrefix(problem.Hint, "server hint; ") || !strings.Contains(problem.Hint, "--type docx") {
t.Fatalf("hint = %q, want the server hint kept and command guidance appended", problem.Hint)
}
before := problem.Hint
if _, ok := errs.ProblemOf(decorateDriveUpdateTitleError(existing, docxSpec)); !ok {
t.Fatal("second decoration should stay typed")
}
if problem.Hint != before {
t.Fatalf("hint = %q, want unchanged on a second decoration", problem.Hint)
}
// An unmapped code leaves the error untouched.
other := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(1061003)
if got := decorateDriveUpdateTitleError(other, docxSpec); got != error(other) {
t.Fatalf("unmapped code should pass through unchanged, got %v", got)
}
if hint := other.Hint; hint != "" {
t.Fatalf("hint = %q, want empty for an unmapped code", hint)
}
}
func TestDriveUpdateTitleExecuteDocxURL(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/docxRenameTarget",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--url", "https://example.larksuite.com/docx/docxRenameTarget",
"--title", " Q3 plan ",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(capturedQuery, "type=docx") {
t.Fatalf("query = %q, want type=docx", capturedQuery)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(stub.CapturedBody))
}
// Surrounding spaces are stripped before the title is sent.
if got := requestBody["new_title"]; got != "Q3 plan" {
t.Fatalf("body new_title = %#v, want the trimmed title", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := data["updated"]; got != true {
t.Fatalf("updated = %#v, want true", got)
}
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxRenameTarget" {
t.Fatalf("file_token = %q, want docxRenameTarget", got)
}
if got := mustStringField(t, data, "type", "data.type"); got != "docx" {
t.Fatalf("type = %q, want docx", got)
}
if got := mustStringField(t, data, "title", "data.title"); got != "Q3 plan" {
t.Fatalf("title = %q, want the trimmed title", got)
}
if got := mustStringField(t, data, "url", "data.url"); got != "https://www.feishu.cn/docx/docxRenameTarget" {
t.Fatalf("url = %q, want the built docx URL", got)
}
if progress := stderr.String(); !strings.Contains(progress, "Updating docx") {
t.Fatalf("stderr = %q, want a progress line naming the target type", progress)
}
}
func TestDriveUpdateTitleExecuteWikiNodeToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/wikiRenameTarget",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "wikiRenameTarget",
"--type", "wiki",
"--new-title", "Renamed node",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The wiki node token is patched as-is: no get_node unwrapping step, because
// the endpoint resolves wiki tokens itself and rejects the document token.
if !strings.Contains(capturedQuery, "type=wiki") {
t.Fatalf("query = %q, want type=wiki", capturedQuery)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "url", "data.url"); got != "https://www.feishu.cn/wiki/wikiRenameTarget" {
t.Fatalf("url = %q, want the built wiki URL", got)
}
if got := mustStringField(t, data, "title", "data.title"); got != "Renamed node" {
t.Fatalf("title = %q, want the --new-title alias value", got)
}
}
func TestDriveUpdateTitleExecuteAPIErrorCarriesGuidance(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/sheetRenameTarget",
Body: map[string]interface{}{
"code": 981003,
"msg": "not found.",
},
})
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "sheetRenameTarget",
"--type", "sheet",
"--title", "Renamed sheet",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected API error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != 981003 {
t.Fatalf("problem code = %d, want 981003", problem.Code)
}
if !strings.Contains(problem.Hint, "--type sheet") {
t.Fatalf("hint = %q, want type/token mismatch guidance", problem.Hint)
}
}
func TestDriveUpdateTitleMountedDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--url", "https://example.larksuite.com/drive/folder/folderRenameTarget",
"--title", "Archive 2026",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want a single planned request", data["api"])
}
call := mustMapValue(t, api[0], "api.0")
if got := call["method"]; got != "PATCH" {
t.Fatalf("api.0.method = %#v, want PATCH", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/files/folderRenameTarget" {
t.Fatalf("api.0.url = %#v, want the files patch endpoint with :file_token resolved", got)
}
params := mustMapValue(t, call["params"], "api.0.params")
if got := params["type"]; got != "folder" {
t.Fatalf("api.0.params.type = %#v, want folder", got)
}
body := mustMapValue(t, call["body"], "api.0.body")
if got := body["new_title"]; got != "Archive 2026" {
t.Fatalf("api.0.body.new_title = %#v, want Archive 2026", got)
}
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "folderRenameTarget" {
t.Fatalf("file_token = %q, want the token parsed from the folder URL", got)
}
}
// Validation runs before the dry-run preview, so a bad target is rejected with a
// typed error rather than previewed — including under --dry-run.
func TestDriveUpdateTitleDryRunRejectsBareTokenWithoutType(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "docxRenameTarget",
"--title", "New title",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected a validation error, got nil")
}
if !strings.Contains(err.Error(), "--type is required") {
t.Fatalf("error = %v, want the missing --type validation message", err)
}
assertDriveUpdateTitleValidationError(t, err, "--type")
}
// driveUpdateTitleMetaStub answers the extension guard's current-name lookup.
func driveUpdateTitleMetaStub(token, title string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"metas": []interface{}{
map[string]interface{}{
"doc_token": token,
"doc_type": "file",
"title": title,
},
},
},
},
}
}
func TestDriveUpdateTitleFileKeepsExtension(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", "quarterly.xlsx"))
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "2026 Q3 report",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
if got := requestBody["new_title"]; got != "2026 Q3 report.xlsx" {
t.Fatalf("body new_title = %#v, want the current extension appended", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "title", "data.title"); got != "2026 Q3 report.xlsx" {
t.Fatalf("title = %q, want the applied title with the extension", got)
}
if got := mustStringField(t, data, "extension_appended", "data.extension_appended"); got != ".xlsx" {
t.Fatalf("extension_appended = %q, want .xlsx", got)
}
if got := mustStringField(t, data, "previous_title", "data.previous_title"); got != "quarterly.xlsx" {
t.Fatalf("previous_title = %q, want the name read before the rename", got)
}
if progress := stderr.String(); !strings.Contains(progress, "keeping .xlsx") {
t.Fatalf("stderr = %q, want the appended extension reported", progress)
}
}
func TestDriveUpdateTitleFileKeepsExtensionCase(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", "quarterly.XLSX"))
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "2026 Q3 report",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
if got := requestBody["new_title"]; got != "2026 Q3 report.XLSX" {
t.Fatalf("body new_title = %#v, want the current extension with its original case", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "extension_appended", "data.extension_appended"); got != ".XLSX" {
t.Fatalf("extension_appended = %q, want the original extension spelling", got)
}
}
// A padded title is trimmed first, so the extension lands at the end of the
// real name instead of after the padding.
func TestDriveUpdateTitleFileKeepsExtensionOnPaddedTitle(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", "notes.md"))
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", " notes v2 ",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
if got := requestBody["new_title"]; got != "notes v2.md" {
t.Fatalf("body new_title = %#v, want the trimmed title with the extension appended", got)
}
}
// A padded title that already ends in the current extension is not given a
// second one.
func TestDriveUpdateTitleFilePaddedTitleWithExtensionIsNotDoubled(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", "notes.md"))
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", " notes v2.md ",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
if got := requestBody["new_title"]; got != "notes v2.md" {
t.Fatalf("body new_title = %#v, want the trimmed title", got)
}
}
func TestDriveUpdateTitleFileKeepsMatchingExtensionVerbatim(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", "quarterly.XLSX"))
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "2026 Q3 report.xlsx",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
// Case-insensitive match: the title already carries the extension, so it is
// submitted verbatim instead of gaining a second one.
if got := requestBody["new_title"]; got != "2026 Q3 report.xlsx" {
t.Fatalf("body new_title = %#v, want the title verbatim", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if _, ok := data["extension_appended"]; ok {
t.Fatalf("extension_appended should be absent when nothing was appended: %#v", data)
}
}
func TestDriveUpdateTitleFileRejectsExtensionChange(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", "notes.md"))
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Optional: true,
OnMatch: func(*http.Request) {
t.Error("the rename must not be sent when the extension would change")
},
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "notes.txt",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected an extension-change rejection, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(problem.Message, ".md") || !strings.Contains(problem.Message, ".txt") {
t.Fatalf("message = %q, want both extensions named", problem.Message)
}
if !strings.Contains(problem.Hint, "--on-extension-mismatch=allow") {
t.Fatalf("hint = %q, want the allow escape hatch", problem.Hint)
}
}
func TestDriveUpdateTitleFileAllowPolicySkipsCurrentTitleRead(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Optional: true,
OnMatch: func(*http.Request) {
t.Error("allow must not read the current title")
},
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "no-extension-on-purpose",
"--on-extension-mismatch", "allow",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
if got := requestBody["new_title"]; got != "no-extension-on-purpose" {
t.Fatalf("body new_title = %#v, want the title verbatim", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if _, ok := data["previous_title"]; ok {
t.Fatalf("previous_title should be absent when the guard did not read it: %#v", data)
}
}
// A dotfile has no extension to protect, so the guard leaves the title alone.
func TestDriveUpdateTitleFileDotfileNeedsNoExtension(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(driveUpdateTitleMetaStub("fileRenameTarget", ".gitignore"))
patch := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(patch)
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "ignore-rules",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(patch.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v", err)
}
if got := requestBody["new_title"]; got != "ignore-rules" {
t.Fatalf("body new_title = %#v, want the title verbatim", got)
}
}
func TestDriveUpdateTitleFileCurrentTitleReadFailureCarriesEscape(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{"code": 1061004, "msg": "forbidden"},
})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Optional: true,
OnMatch: func(*http.Request) {
t.Error("the rename must not be sent when the guard could not run")
},
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "renamed.xlsx",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected the current-title read failure to surface, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != 1061004 {
t.Fatalf("problem code = %d, want the metadata read code 1061004", problem.Code)
}
if !strings.Contains(problem.Hint, "--on-extension-mismatch=allow") {
t.Fatalf("hint = %q, want the allow escape hatch", problem.Hint)
}
}
func TestDriveUpdateTitleFileRejectsMissingCurrentTitle(t *testing.T) {
for _, test := range []struct {
name string
metas interface{}
}{
{name: "empty metas", metas: []interface{}{}},
{name: "missing title", metas: []interface{}{map[string]interface{}{"doc_token": "fileRenameTarget", "doc_type": "file"}}},
} {
t.Run(test.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"metas": test.metas},
},
})
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v1/files/fileRenameTarget",
Optional: true,
OnMatch: func(*http.Request) {
t.Error("the rename must not be sent without a current title")
},
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
})
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--token", "fileRenameTarget",
"--type", "file",
"--title", "renamed",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected missing current title to fail")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
}
})
}
}
func TestDriveUpdateTitleDryRunPlansExtensionGuardRead(t *testing.T) {
t.Parallel()
planCalls := func(spec driveUpdateTitleSpec) []map[string]interface{} {
t.Helper()
raw, err := json.Marshal(buildDriveUpdateTitleDryRun(spec))
if err != nil {
t.Fatalf("marshal dry-run plan: %v", err)
}
plan := decodeJSONMap(t, string(raw))
calls, ok := plan["api"].([]interface{})
if !ok {
t.Fatalf("api = %#v, want a call list", plan["api"])
}
out := make([]map[string]interface{}, 0, len(calls))
for i, call := range calls {
out = append(out, mustMapValue(t, call, fmt.Sprintf("api.%d", i)))
}
return out
}
spec := driveUpdateTitleSpec{
Ref: driveUpdateTitleRef{Token: "fileRenameTarget", Type: "file", SourceFlag: "--token"},
Title: "renamed",
ExtPolicy: driveUpdateTitleExtKeep,
}
calls := planCalls(spec)
if len(calls) != 2 {
t.Fatalf("plan has %d calls, want the metadata read plus the rename", len(calls))
}
if got := calls[0]["url"]; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("api.0.url = %#v, want the metadata read", got)
}
if got := calls[1]["url"]; got != "/open-apis/drive/v1/files/fileRenameTarget" {
t.Fatalf("api.1.url = %#v, want the files patch endpoint", got)
}
body := mustMapValue(t, calls[1]["body"], "api.1.body")
if got := body["new_title"]; got != "<resolved from --title and current title in step 1>" {
t.Fatalf("api.1.body.new_title = %#v, want response-derived placeholder", got)
}
// allow renames without reading the current name, so the plan is one request.
spec.ExtPolicy = driveUpdateTitleExtAllow
if calls := planCalls(spec); len(calls) != 1 {
t.Fatalf("plan has %d calls under the allow policy, want 1", len(calls))
}
}
func TestDriveUpdateTitleMountedRejectsWhitespaceTitle(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveUpdateTitle, []string{
"+update-title",
"--url", "https://example.larksuite.com/docx/docxRenameTarget",
"--title", " ",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected a validation error, got nil")
}
assertDriveUpdateTitleValidationError(t, err, "--title")
}
func assertDriveUpdateTitleValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
}
}