feat(common): backfill resource URL when create APIs omit it (#680)

Add BuildResourceURL helper and wire it into doc/sheets/drive/base/wiki
create paths so callers always receive a clickable link, even when the
backend response (MCP degraded path or upstream OpenAPI) returns an
empty URL field. The fallback uses the brand-standard host
(www.feishu.cn / www.larksuite.com), which redirects to the tenant
domain.

Affected entries:
- docs +create v1 / v2
- sheets +create
- drive +create-folder / +import / +upload (newly exposes url)
- wiki +node-create (newly exposes url)

drive +create-shortcut is intentionally skipped because the URL form
depends on the underlying file kind, which the shortcut payload does
not carry.
This commit is contained in:
caojie0621
2026-04-28 18:20:35 +08:00
committed by GitHub
parent 9ba0d15161
commit fc22e9a04b
16 changed files with 791 additions and 4 deletions

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"strings"
"github.com/larksuite/cli/internal/core"
)
// BuildResourceURL returns a brand-standard, user-facing URL for a freshly
// created Lark resource. It is intended as a fallback when the create API does
// not return a URL field (e.g. drive +upload, wiki +node-create) or when the
// returned URL is empty (e.g. degraded MCP responses for docs +create v1).
//
// The returned URL points at the brand's standard host (www.feishu.cn /
// www.larksuite.com), which transparently redirects to the tenant-specific
// domain. It is NOT a guess at the tenant's vanity domain.
//
// Returns "" when token is empty or kind is unrecognized — callers should
// only set the field when the result is non-empty so that "" never overrides
// a real URL the backend already returned.
func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
host := "https://www.feishu.cn"
if brand == core.BrandLark {
host = "https://www.larksuite.com"
}
switch strings.ToLower(strings.TrimSpace(kind)) {
case "docx":
return host + "/docx/" + token
case "doc":
return host + "/doc/" + token
case "sheet":
return host + "/sheets/" + token
case "bitable":
return host + "/base/" + token
case "wiki":
return host + "/wiki/" + token
case "file":
return host + "/file/" + token
case "folder":
return host + "/drive/folder/" + token
case "mindnote":
return host + "/mindnote/" + token
case "slides":
return host + "/slides/" + token
default:
return ""
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestBuildResourceURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
brand core.LarkBrand
kind string
token string
want string
}{
{"feishu docx", core.BrandFeishu, "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
{"feishu doc legacy", core.BrandFeishu, "doc", "doccnABC", "https://www.feishu.cn/doc/doccnABC"},
{"feishu sheet", core.BrandFeishu, "sheet", "shtcnABC", "https://www.feishu.cn/sheets/shtcnABC"},
{"feishu bitable", core.BrandFeishu, "bitable", "bascnABC", "https://www.feishu.cn/base/bascnABC"},
{"feishu wiki", core.BrandFeishu, "wiki", "wikcnABC", "https://www.feishu.cn/wiki/wikcnABC"},
{"feishu file", core.BrandFeishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"},
{"feishu folder", core.BrandFeishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"},
{"feishu mindnote", core.BrandFeishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"},
{"feishu slides", core.BrandFeishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"},
{"lark docx", core.BrandLark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"},
{"lark wiki", core.BrandLark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"},
{"empty brand defaults to feishu", core.LarkBrand(""), "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
{"kind case-insensitive", core.BrandFeishu, "DOCX", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
{"token whitespace trimmed", core.BrandFeishu, "docx", " doxcnABC ", "https://www.feishu.cn/docx/doxcnABC"},
{"empty token", core.BrandFeishu, "docx", "", ""},
{"whitespace-only token", core.BrandFeishu, "docx", " ", ""},
{"unknown kind", core.BrandFeishu, "calendar", "calABC", ""},
{"empty kind", core.BrandFeishu, "", "doxcnABC", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BuildResourceURL(tt.brand, tt.kind, tt.token)
if got != tt.want {
t.Errorf("BuildResourceURL(%q, %q, %q) = %q, want %q", tt.brand, tt.kind, tt.token, got, tt.want)
}
})
}
}

View File

@@ -150,6 +150,24 @@ func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]int
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
fallbackDocURLV1(runtime, result)
}
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
// response did not include one but did include a doc_id. This protects against
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
// drops structured fields.
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
return
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID == "" {
return
}
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
result["doc_url"] = u
}
}
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {

View File

@@ -182,6 +182,67 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
}
}
func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
// "url" deliberately omitted to exercise the fallback.
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
t.Fatalf("missing document in envelope: %#v", data)
}
if got, want := doc["url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("document.url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://tenant.larkoffice.com/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
doc, _ := data["document"].(map[string]interface{})
if got, want := doc["url"], "https://tenant.larkoffice.com/docx/doxcn_new_doc"; got != want {
t.Fatalf("document.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
@@ -273,6 +334,60 @@ func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
}
}
func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"message": "文档创建成功",
// "doc_url" deliberately omitted to exercise the fallback.
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
}
}
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {

View File

@@ -51,6 +51,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
}
augmentDocsCreatePermission(runtime, data)
fallbackDocsCreateURLV2(runtime, data)
runtime.OutRaw(data, nil)
return nil
}
@@ -84,3 +85,23 @@ func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string
data["permission_grant"] = grant
}
}
// fallbackDocsCreateURLV2 fills data.document.url with a brand-standard URL
// when the OpenAPI response did not include one. Backfills only when missing,
// so any tenant-specific URL the backend returned is preserved.
func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return
}
if strings.TrimSpace(common.GetString(doc, "url")) != "" {
return
}
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
if docID == "" {
return
}
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
doc["url"] = u
}
}

View File

@@ -92,8 +92,10 @@ var DriveCreateFolder = common.Shortcut{
"folder_token": folderToken,
"parent_folder_token": spec.FolderToken,
}
if url := common.GetString(data, "url"); url != "" {
if url := strings.TrimSpace(common.GetString(data, "url")); url != "" {
out["url"] = url
} else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" {
out["url"] = u
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
out["permission_grant"] = grant

View File

@@ -232,6 +232,72 @@ func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
}
}
func TestDriveCreateFolderFallbackURLWhenBackendOmitsIt(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"token": "fld_created",
// "url" deliberately omitted to exercise the fallback.
},
},
})
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", "Weekly Reports",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want {
t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestDriveCreateFolderFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"token": "fld_created",
"url": " ", // whitespace-only must trigger fallback, not pass through.
},
},
})
err := mountAndRunDrive(t, DriveCreateFolder, []string{
"+create-folder",
"--name", "Weekly Reports",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want {
t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
}
}
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
t.Parallel()

View File

@@ -119,8 +119,12 @@ var DriveImport = common.Shortcut{
if status.Token != "" {
out["token"] = status.Token
}
if status.URL != "" {
out["url"] = status.URL
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
@@ -205,6 +209,20 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
})
}
// normalizeDriveImportKindForURL maps the server's import "type" field to a
// canonical kind BuildResourceURL recognizes. status.DocType comes straight
// from the API and isn't normalized; if it ever returns aliases like "sheets"
// or "sheet_v2" the URL construction would silently fall through. Fall back
// to the user-supplied --type, which is already validated to docx/sheet/
// bitable, so out.url stays populated whenever status.Token is set.
func normalizeDriveImportKindForURL(serverType, fallback string) string {
switch strings.ToLower(strings.TrimSpace(serverType)) {
case "docx", "sheet", "bitable":
return strings.ToLower(strings.TrimSpace(serverType))
}
return fallback
}
// importTargetFileName returns the explicit import name when present, otherwise
// derives one from the local file name.
func importTargetFileName(filePath, explicitName string) string {

View File

@@ -12,6 +12,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -362,3 +365,206 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
t.Fatalf("mount_key = %q, want %q", got, "fld_test")
}
}
// driveImportMockEnv mounts the three stubs needed for a full +import run:
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.
func driveImportMockEnv(t *testing.T, reg *httpmock.Registry, ticket string, pollData map[string]interface{}) {
t.Helper()
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_import_media"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": ticket},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/" + ticket,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": pollData},
},
})
}
// driveImportTestConfig builds a CliConfig for the import fallback tests.
// The brand defaults to BrandFeishu when omitted; pass core.BrandLark to
// exercise the larksuite.com branch of BuildResourceURL.
func driveImportTestConfig(suffix string, brands ...core.LarkBrand) *core.CliConfig {
brand := core.BrandFeishu
if len(brands) > 0 {
brand = brands[0]
}
return &core.CliConfig{
AppID: "drive-import-fallback-" + suffix,
AppSecret: "test-secret",
Brand: brand,
}
}
func TestDriveImportFallbackURLWhenBackendOmitsIt(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("missing-url"))
driveImportMockEnv(t, reg, "ticket_fallback", map[string]interface{}{
"token": "doxcn_imported",
"type": "docx",
"job_status": float64(0),
// "url" deliberately omitted: import API frequently returns the doc
// without an absolute URL, leaving the CLI to backfill from token.
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want {
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestDriveImportPreservesBackendURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("preserve-url"))
driveImportMockEnv(t, reg, "ticket_preserve", map[string]interface{}{
"token": "doxcn_imported",
"type": "docx",
"job_status": float64(0),
"url": "https://tenant.larkoffice.com/docx/doxcn_imported",
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://tenant.larkoffice.com/docx/doxcn_imported"; got != want {
t.Fatalf("data.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
}
}
func TestDriveImportFallbackURLWhenServerURLIsWhitespace(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("whitespace-url"))
driveImportMockEnv(t, reg, "ticket_whitespace", map[string]interface{}{
"token": "doxcn_imported",
"type": "docx",
"job_status": float64(0),
"url": " ", // whitespace-only must trigger fallback, not pass through.
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want {
t.Fatalf("data.url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
}
}
func TestDriveImportFallbackURLForLarkBrand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("lark-brand", core.BrandLark))
driveImportMockEnv(t, reg, "ticket_lark", map[string]interface{}{
"token": "doxcn_imported",
"type": "docx",
"job_status": float64(0),
// "url" omitted to force the fallback through the lark host branch.
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.larksuite.com/docx/doxcn_imported"; got != want {
t.Fatalf("data.url = %#v, want %q (lark brand fallback)", got, want)
}
}
func TestDriveImportFallbackURLWhenServerTypeIsAlias(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("alias-type"))
driveImportMockEnv(t, reg, "ticket_alias", map[string]interface{}{
"token": "shtcn_imported",
"type": "sheets", // non-canonical alias the server may return
"job_status": float64(0),
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := mountAndRunDrive(t, DriveImport, []string{
"+import", "--file", "data.csv", "--type", "sheet", "--as", "user",
}, f, stdout); err != nil {
t.Fatalf("import should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
// Server returned "sheets" (alias) — normalize falls back to the user
// --type "sheet", so BuildResourceURL picks the canonical /sheets/ path.
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_imported"; got != want {
t.Fatalf("data.url = %#v, want %q (alias normalized via spec.DocType fallback)", got, want)
}
}

View File

@@ -317,6 +317,84 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
}
}
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
// upload_all only ever returns file_token; url is never present —
// this exercises the fallback path unconditionally for explorer
// parents.
"data": map[string]interface{}{"file_token": "file_explorer_small"},
},
})
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir() error: %v", err)
}
defer os.Chdir(origDir)
if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "hello.bin", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("upload should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_wiki_small"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "hello.bin",
"--wiki-token", "wikcn_parent",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("upload should succeed, got: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["url"]; ok {
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
}
}
func TestDriveUploadSmallFileAPIError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -146,6 +146,14 @@ var DriveUpload = common.Shortcut{
"file_name": fileName,
"size": fileSize,
}
// wiki-hosted files have no standalone /file/<token> URL — only the
// wiki node URL, which the upload response doesn't carry. Skip the
// fallback for parent_type=wiki rather than emit a link that 404s.
if target.ParentType == driveUploadParentTypeExplorer {
if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" {
out["url"] = u
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
out["permission_grant"] = grant
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -108,7 +109,12 @@ var SheetCreate = common.Shortcut{
out := map[string]interface{}{
"spreadsheet_token": token,
"title": title,
"url": spreadsheet["url"],
}
url, _ := spreadsheet["url"].(string)
if url = strings.TrimSpace(url); url != "" {
out["url"] = url
} else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" {
out["url"] = u
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil {
out["permission_grant"] = grant

View File

@@ -110,6 +110,142 @@ func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
}
}
func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
// "url" deliberately omitted to exercise the fallback.
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
}
}
func TestSheetCreatePreservesBackendURL(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
}
}
func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": " ", // whitespace-only must trigger fallback, not pass through.
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
}
}
func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{
"spreadsheet_token": "shtcn_new_sheet",
"url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ",
},
},
},
})
err := runSheetCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目排期",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSheetCreateEnvelope(t, stdout)
if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want)
}
}
func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()

View File

@@ -479,5 +479,8 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
out["permission_grant"] = grant
}
if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" {
out["url"] = u
}
return out
}

View File

@@ -502,6 +502,9 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
if envelope.Data["node_token"] != "wik_created" {
t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created")
}
if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want {
t.Fatalf("url = %#v, want %q", got, want)
}
var captured map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {

View File