diff --git a/docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md b/docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md new file mode 100644 index 00000000..158452fd --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md @@ -0,0 +1,55 @@ +# OA Attachment Download URL Output Design + +## Goal + +Keep the existing command and MCP request unchanged while making the returned +OSS signed URL directly copyable from JSON output: + +```text +dws oa approval attachment download-url +``` + +## Scope + +Only `oa approval attachment download-url` changes. The other OA attachment +commands and the global JSON formatter retain their current behavior. + +## Design + +The command continues to invoke MCP server `oa`, tool +`get_attachment_download_url`, with the same arguments. Its leaf declaration +provides a command-specific `Call` callback that invokes the existing MCP +dispatcher with HTML escaping disabled when the selected output format is +JSON. This preserves literal `&` separators in `result.downloadUri` instead of +rendering them as `\u0026`. + +For `raw`, `table`, and other non-JSON formats, the callback uses the existing +escaped dispatcher behavior so their current rendering remains unchanged. + +The change does not alter the URL, decode or re-sign it, download the file, or +change global JSON serialization. + +## Error Handling + +Authentication, MCP transport, gateway, PAT, and business errors continue +through the existing dispatcher and retain their current classification and +output behavior. + +## Verification + +Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra +leaf in explicit JSON mode with a fake MCP result containing a signed URL. It +must verify: + +- the request still targets `oa/get_attachment_download_url`; +- the exact request arguments remain unchanged, including omission of the + optional boolean when the flag was not supplied; +- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`; +- stdout contains no `\u0026` escape. + +The fake caller must report JSON format (or the command must be executed with +`--format json`) so the test fails against the current escaped JSON path rather +than accidentally exercising raw MCP text output. + +Run the focused OA attachment tests, format modified Go files, and rebuild the +CLI. No commit is created. diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go new file mode 100644 index 00000000..75d40694 --- /dev/null +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) { + tests := []struct { + cliPath string + canonical string + rpc string + description string + effect string + resultType string + resultFields map[string]string + sensitivePaths []string + }{ + { + cliPath: "oa approval attachment download-url", + canonical: "oa.get_attachment_download_url", + rpc: "get_attachment_download_url", + description: "获取审批附件下载授权并生成临时下载链接", + effect: "read", + resultType: "object", + resultFields: map[string]string{ + "spaceId": "integer", "agentId": "integer", "downloadUri": "string", + "class": "string", "fileId": "string", + }, + sensitivePaths: []string{"downloadUri"}, + }, + { + cliPath: "oa approval attachment authorize-download", + canonical: "oa.auth_download_file", + rpc: "auth_download_file", + description: "批量授权当前用户下载指定的审批钉盘文件", + effect: "write", + resultType: "boolean", + }, + { + cliPath: "oa approval attachment authorize-preview", + canonical: "oa.auth_preview_attachment", + rpc: "auth_preview_attachment", + description: "批量授权当前用户预览审批单中的附件", + effect: "write", + resultType: "object", + resultFields: map[string]string{ + "spaceId": "integer", "agentId": "integer", "class": "string", + }, + }, + } + + for _, test := range tests { + t.Run(test.rpc, func(t *testing.T) { + root := NewRootCommand() + command := exactCommandForTest(root, test.cliPath) + if command == nil { + t.Fatalf("executable command %q is missing", test.cliPath) + } + + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"schema", test.cliPath, "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String()) + } + + var tool map[string]any + if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil { + t.Fatalf("decode delivery schema leaf: %v", err) + } + if got := schemaContractString(tool["canonical_path"]); got != test.canonical { + t.Fatalf("canonical_path = %q, want %q", got, test.canonical) + } + if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath { + t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath) + } + if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) { + t.Fatalf("description = %q, want prefix %q", got, test.description) + } + if got := schemaContractString(tool["interface_mode"]); got != "mcp" { + t.Fatalf("interface_mode = %q, want mcp", got) + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Fatalf("availability = %q, want available", got) + } + interfaceRef := schemaInterfaceObject(tool["interface_ref"]) + if got := schemaContractString(interfaceRef["product_id"]); got != "oa" { + t.Fatalf("interface_ref.product_id = %q, want oa", got) + } + if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc { + t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc) + } + if got := schemaContractString(tool["effect"]); got != test.effect { + t.Fatalf("effect = %q, want %q", got, test.effect) + } + if got := schemaContractString(tool["risk"]); got != "low" { + t.Fatalf("risk = %q, want low", got) + } + if got := schemaContractString(tool["confirmation"]); got != "not_required" { + t.Fatalf("confirmation = %q, want not_required", got) + } + if got := schemaContractString(tool["idempotency"]); got != "idempotent" { + t.Fatalf("idempotency = %q, want idempotent", got) + } + fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths) + + stdout.Reset() + stderr.Reset() + root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String()) + } + var compactTool map[string]any + if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil { + t.Fatalf("decode compact delivery schema leaf: %v", err) + } + compactResult, ok := compactTool["result"].(map[string]any) + if !ok { + t.Fatalf("compact result = %#v, want object", compactTool["result"]) + } + if !reflect.DeepEqual(compactResult, fullResult) { + t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult) + } + if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" { + t.Fatal(problem) + } + }) + } +} + +func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any { + t.Helper() + result, ok := tool["result"].(map[string]any) + if !ok { + t.Fatalf("full result = %#v, want object", tool["result"]) + } + if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) { + t.Fatalf("result.outcomes = %#v, want %#v", got, want) + } + if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) { + t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths) + } + dataSchema, ok := result["data_schema"].(map[string]any) + if !ok || schemaContractString(dataSchema["type"]) != resultType { + t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType) + } + properties, _ := dataSchema["properties"].(map[string]any) + if len(properties) != len(fields) { + t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields) + } + for name, fieldType := range fields { + property, ok := properties[name].(map[string]any) + if !ok || schemaContractString(property["type"]) != fieldType { + t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType) + } + } + return result +} diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 87413498..8dfe47b5 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -10,7 +10,10 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/spf13/cobra" ) @@ -42,6 +45,283 @@ func oaFormValues(raw string) ([]map[string]string, error) { return result, nil } +func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var items []map[string]any + if err := decoder.Decode(&items); err != nil { + return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err) + } + if err := rejectTrailingOAAttachmentJSON(decoder); err != nil { + return nil, err + } + if len(items) < 1 || len(items) > 10 { + return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件") + } + + infos := make([]map[string]any, 0, len(items)) + for index, item := range items { + for name := range item { + if name != "spaceId" && name != "fileId" { + return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name) + } + } + + spaceValue, ok := item["spaceId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1) + } + spaceID, ok := spaceValue.(json.Number) + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1) + } + + fileValue, ok := item["fileId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1) + } + fileID, ok := fileValue.(string) + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串", index+1) + } + fileID = strings.TrimSpace(fileID) + if fileID == "" { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1) + } + + infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID}) + } + return infos, nil +} + +func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("--file-infos JSON 包含多余内容") + } + return fmt.Errorf("--file-infos JSON 解析失败: %w", err) + } + return nil +} + +func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error { + raw, _ := cmd.Flags().GetString("file-infos") + _, err := parseOAAttachmentFileInfos(raw) + return err +} + +func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { + fileIDs, _ := cmd.Flags().GetStringSlice("file-ids") + if len(fileIDs) > 20 { + return fmt.Errorf("--file-ids 最多包含 20 个附件 ID") + } + for index, fileID := range fileIDs { + if strings.TrimSpace(fileID) == "" { + return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1) + } + } + return nil +} + +func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args) + if err != nil { + return nil, err + } + response, ok := data.(map[string]any) + if !ok { + return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值不是 JSON 对象", tool)) + } + result, ok := response["result"] + if !ok { + return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值缺少 result", tool)) + } + return output.Success(result), nil +} + +func newOAAttachmentCommand() *cobra.Command { + attachmentCmd := &cobra.Command{ + Use: "attachment", + Short: "审批附件授权与下载链接", + RunE: groupRunE, + } + + downloadURLCmd := NewLeafCommand(LeafSpec{ + Use: "download-url", + Short: "获取审批附件下载链接", + Example: " dws oa approval attachment download-url --instance-id --file-id ", + Server: "oa", + Tool: "get_attachment_download_url", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, + Flags: []LeafFlag{ + {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true}, + {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_attachment_download_url", + CanonicalPath: "oa.get_attachment_download_url", + CLIPath: "oa approval attachment download-url", + PrimaryCLIPath: "oa approval attachment download-url", + }, + Description: "获取审批附件下载授权并生成临时下载链接", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件临时下载信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"downloadUri":{"type":"string","description":"带临时授权签名的附件下载链接"},"class":{"type":"string","description":"服务端响应类型标识"},"fileId":{"type":"string","description":"审批附件文件 ID"}},"required":["spaceId","agentId","downloadUri","fileId"],"additionalProperties":true}`), + SensitivePaths: []string{"downloadUri"}, + }, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取审批实例中指定附件的临时下载链接", + UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"}, + AvoidWhen: []string{ + "只需查看审批表单和附件元数据时使用 dws oa approval detail", + "需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接", + }, + Examples: []string{"dws oa approval attachment download-url --instance-id --file-id "}, + }, + Parameters: []contract.ParamDecl{ + {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, + {Name: "file-id", Property: "fileId", InterfaceType: "string"}, + {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, + }, + }, + }) + + authorizeDownloadCmd := NewLeafCommand(LeafSpec{ + Use: "authorize-download", + Short: "授权当前用户下载审批钉盘文件", + Long: "批量授权当前用户下载指定的审批钉盘文件。", + Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`, + Server: "oa", + Tool: "auth_download_file", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, + Flags: []LeafFlag{ + { + Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)", + Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true, + Transform: func(raw string) (any, error) { + return parseOAAttachmentFileInfos(raw) + }, + }, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, Flags: []string{"file-infos"}, + Description: "文件信息列表必须包含 1 至 10 项", + }}, + Validate: validateOAAttachmentFileInfos, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "auth_download_file", + CanonicalPath: "oa.auth_download_file", + CLIPath: "oa approval attachment authorize-download", + PrimaryCLIPath: "oa approval attachment authorize-download", + }, + Description: "批量授权当前用户下载指定的审批钉盘文件", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"boolean","description":"是否成功为当前用户授予审批钉盘文件下载权限"}`), + }, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件", + UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"}, + AvoidWhen: []string{ + "需要生成单个审批附件下载链接时使用 attachment download-url", + "需要授权在审批单内预览附件时使用 attachment authorize-preview", + }, + Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`}, + }, + Parameters: []contract.ParamDecl{ + {Name: "file-infos", Property: "fileInfos", InterfaceType: "array"}, + }, + }, + }) + + authorizePreviewCmd := NewLeafCommand(LeafSpec{ + Use: "authorize-preview", + Short: "授权当前用户预览审批附件", + Long: "批量授权当前用户预览审批单中的附件。", + Example: " dws oa approval attachment authorize-preview --instance-id --file-ids ,", + Server: "oa", + Tool: "auth_preview_attachment", + OutputRollout: output.RolloutUnifiedActive, + ResultCall: callOAAttachmentResult, + Flags: []LeafFlag{ + {Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true}, + {Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true}, + {Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"}, + }, + Constraints: []LeafConstraint{{ + Kind: corecmd.Custom, Flags: []string{"file-ids"}, + Description: "附件 ID 列表最多包含 20 项且每项不能为空", + }}, + Validate: validateOAPreviewFileIDs, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "auth_preview_attachment", + CanonicalPath: "oa.auth_preview_attachment", + CLIPath: "oa approval attachment authorize-preview", + PrimaryCLIPath: "oa approval attachment authorize-preview", + }, + Description: "批量授权当前用户预览审批单中的附件", + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件预览授权信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["spaceId","agentId"],"additionalProperties":true}`), + }, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件", + UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"}, + AvoidWhen: []string{ + "需要下载权限而不是预览权限时使用 attachment authorize-download", + "需要直接获得单个附件临时下载链接时使用 attachment download-url", + }, + Examples: []string{"dws oa approval attachment authorize-preview --instance-id --file-ids ,"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"}, + {Name: "file-ids", Property: "fileIdList", InterfaceType: "array"}, + {Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"}, + }, + }, + }) + + attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd) + return attachmentCmd +} + // ────────────────────────────────────────────────────────── // dws oa — OA 审批 // MCP tools(tools/list): list_pending_approvals, get_processInstance_detail, @@ -49,7 +329,8 @@ func oaFormValues(raw string) ([]map[string]string, error) { // get_processInstance_records, list_initiated_instances, list_pending_tasks, // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, -// start_process_instance +// start_process_instance, get_attachment_download_url, auth_download_file, +// auth_preview_attachment // ────────────────────────────────────────────────────────── func newOaCommand() *cobra.Command { @@ -58,9 +339,10 @@ func newOaCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "oa", Selection: contract.ProductSelectionDecl{ - AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送与评论", + AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送、评论与附件授权", UseWhen: []string{ "查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时", + "获取审批附件下载链接,或为当前用户授权下载、预览审批附件时", }, AvoidWhen: []string{ "不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume", @@ -70,7 +352,7 @@ func newOaCommand() *cobra.Command { root := &cobra.Command{ Use: "oa", Short: "OA 审批 / 同意 / 拒绝 / 撤销", - Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`, + Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`, RunE: groupRunE, } @@ -1376,6 +1658,7 @@ func newOaCommand() *cobra.Command { approvalForecastCmd, approvalCreateCmd, ) + approvalCmd.AddCommand(newOAAttachmentCommand()) root.AddCommand(approvalCmd) return root diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go new file mode 100644 index 00000000..230e9021 --- /dev/null +++ b/internal/helpers/oa_attachment_test.go @@ -0,0 +1,339 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" +) + +func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToolCaller, args ...string) (string, error) { + t.Helper() + testseam.Protect(t, &deps) + testseam.Swap(t, &os.Args, []string{"dws", "oa"}) + InitDeps(caller) + var stdout bytes.Buffer + deps.Out.w = &stdout + deps.Out.errW = io.Discard + + cmd := newOaCommand() + cmd.PersistentFlags().Bool("yes", false, "跳过确认") + cmd.PersistentFlags().String("format", caller.Format(), "输出格式") + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(executed) + return err + } + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), err +} + +func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *testing.T) { + const response = `{"result":{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"},"success":true}` + commandArgs := []string{ + "approval", "attachment", "download-url", + "--instance-id", "instance-1", + "--file-id", "file-1", + } + wantArgs := map[string]any{ + "processInstanceId": "instance-1", + "fileId": "file-1", + } + + t.Run("json", func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.server != "oa" || caller.tool != "get_attachment_download_url" { + t.Fatalf("called %s/%s, want oa/get_attachment_download_url", caller.server, caller.tool) + } + if !reflect.DeepEqual(caller.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", caller.args, wantArgs) + } + if !strings.Contains(stdout, "&OSSAccessKeyId=") || !strings.Contains(stdout, "&Signature=") { + t.Fatalf("stdout does not preserve signed query separators: %q", stdout) + } + if strings.Contains(stdout, `\u0026`) { + t.Fatalf("stdout contains escaped query separator: %q", stdout) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || !strings.Contains(data["downloadUri"].(string), "&Signature=") { + t.Fatalf("unified data = %#v", envelope["data"]) + } + }) + + t.Run("raw compatibility", func(t *testing.T) { + caller := &scriptedToolCaller{format: "raw", steps: []scriptedToolStep{{text: response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + const want = `{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"}` + "\n" + if stdout != want { + t.Fatalf("stdout = %q, want %q", stdout, want) + } + }) +} + +func TestCrossPlatformCoverageOAAttachmentProjectsUnifiedBusinessData(t *testing.T) { + tests := []struct { + name string + args []string + response string + wantData any + }{ + { + name: "download authorization", + args: []string{"approval", "attachment", "authorize-download", "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`}, + response: `{"result":true,"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`, + wantData: true, + }, + { + name: "preview authorization", + args: []string{"approval", "attachment", "authorize-preview", "--instance-id", "instance-1", "--file-ids", "file-1"}, + response: `{"result":{"spaceId":27827223951,"agentId":4115627346,"class":"com.dingtalk.bpms.oapi.vo.AppSpaceResponse"},"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`, + wantData: map[string]any{ + "spaceId": float64(27827223951), "agentId": float64(4115627346), + "class": "com.dingtalk.bpms.oapi.vo.AppSpaceResponse", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: test.response}}} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...) + if err != nil { + t.Fatalf("execute command: %v", err) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" || !reflect.DeepEqual(envelope["data"], test.wantData) { + t.Fatalf("unified envelope = %#v, want data %#v", envelope, test.wantData) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsSuccessWithoutResult(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`}}} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "authorize-download", + "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`, + ) + if err == nil || !strings.Contains(err.Error(), "缺少 result") { + t.Fatalf("execute error = %v, want missing result", err) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsInvalidToolResult(t *testing.T) { + tests := []struct { + name string + step scriptedToolStep + want string + }{ + {name: "non-object response", step: scriptedToolStep{text: `[]`}, want: "不是 JSON 对象"}, + {name: "tool error", step: scriptedToolStep{err: errors.New("tool unavailable")}, want: "tool unavailable"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{test.step}} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "authorize-download", + "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`, + ) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("execute error = %v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + check func(*testing.T, map[string]any) + }{ + { + name: "download URL", + args: []string{ + "approval", "attachment", "download-url", + "--instance-id", "instance-1", + "--file-id", "file-1", + "--with-comment-attachment=false", + }, + wantTool: "get_attachment_download_url", + check: func(t *testing.T, got map[string]any) { + t.Helper() + if got["processInstanceId"] != "instance-1" || got["fileId"] != "file-1" { + t.Fatalf("download identifiers = %#v", got) + } + if value, ok := got["withCommentAttachment"].(bool); !ok || value { + t.Fatalf("withCommentAttachment = %#v, want explicit false", got["withCommentAttachment"]) + } + }, + }, + { + name: "download authorization", + args: []string{ + "approval", "attachment", "authorize-download", + "--file-infos", `[{"spaceId":27827223951,"fileId":"file-2"}]`, + }, + wantTool: "auth_download_file", + check: func(t *testing.T, got map[string]any) { + t.Helper() + infos, ok := got["fileInfos"].([]map[string]any) + if !ok || len(infos) != 1 { + t.Fatalf("fileInfos = %#v", got["fileInfos"]) + } + if infos[0]["spaceId"] != json.Number("27827223951") || infos[0]["fileId"] != "file-2" { + t.Fatalf("fileInfos[0] = %#v", infos[0]) + } + }, + }, + { + name: "preview authorization", + args: []string{ + "approval", "attachment", "authorize-preview", + "--instance-id", "instance-2", + "--file-ids", "file-3,file-4", + "--with-comment-attachment", + }, + wantTool: "auth_preview_attachment", + check: func(t *testing.T, got map[string]any) { + t.Helper() + if got["processInstanceId"] != "instance-2" || got["withCommentAttachment"] != true { + t.Fatalf("preview scalar payload = %#v", got) + } + ids, ok := got["fileIdList"].([]string) + if !ok || len(ids) != 2 || ids[0] != "file-3" || ids[1] != "file-4" { + t.Fatalf("fileIdList = %#v", got["fileIdList"]) + } + }, + }, + { + name: "optional boolean omitted", + args: []string{ + "approval", "attachment", "download-url", + "--instance-id", "instance-3", + "--file-id", "file-5", + }, + wantTool: "get_attachment_download_url", + check: func(t *testing.T, got map[string]any) { + t.Helper() + if _, exists := got["withCommentAttachment"]; exists { + t.Fatalf("optional withCommentAttachment should be omitted: %#v", got) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}} + if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...); err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.server != "oa" || caller.tool != test.wantTool { + t.Fatalf("called %s/%s, want oa/%s", caller.server, caller.tool, test.wantTool) + } + test.check(t, caller.args) + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsInvalidFileInfos(t *testing.T) { + valid := `{"spaceId":27827223951,"fileId":"file-1"}` + tests := []struct { + name string + raw string + }{ + {name: "malformed JSON", raw: `[{`}, + {name: "null", raw: `null`}, + {name: "not array", raw: `{}`}, + {name: "empty array", raw: `[]`}, + {name: "more than ten", raw: `[` + strings.Repeat(valid+`,`, 10) + valid + `]`}, + {name: "missing space ID", raw: `[{"fileId":"file-1"}]`}, + {name: "string space ID", raw: `[{"spaceId":"27827223951","fileId":"file-1"}]`}, + {name: "missing file ID", raw: `[{"spaceId":27827223951}]`}, + {name: "numeric file ID", raw: `[{"spaceId":27827223951,"fileId":232271651278}]`}, + {name: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`}, + {name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`}, + {name: "trailing JSON", raw: `[` + valid + `] {}`}, + {name: "malformed trailing JSON", raw: `[` + valid + `] {`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "attachment", "authorize-download", + "--file-infos", test.raw, + ) + if err == nil { + t.Fatalf("fileInfos %q unexpectedly succeeded", test.raw) + } + if caller.calls != 0 { + t.Fatalf("invalid fileInfos made %d MCP call(s)", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "separator only", raw: `,`}, + {name: "blank item", raw: `file-1, ,file-2`}, + {name: "more than twenty", raw: strings.Repeat("file,", 20) + "file"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "attachment", "authorize-preview", + "--instance-id", "instance-1", + "--file-ids", test.raw, + ) + if err == nil { + t.Fatalf("file IDs %q unexpectedly succeeded", test.raw) + } + if caller.calls != 0 { + t.Fatalf("invalid file IDs made %d MCP call(s)", caller.calls) + } + }) + } +} diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 46c32b36..cc82079f 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -27,6 +27,57 @@ Flags: --instance-id string 审批实例 ID (必填) ``` +### 审批附件授权与下载 + +先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: + +- 需要单个附件的临时下载链接:`attachment download-url` +- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` +- 需要在审批场景内批量预览附件:`attachment authorize-preview` + +#### 获取审批附件临时下载链接 + +``` +Usage: + dws oa approval attachment download-url [flags] +Example: + dws oa approval attachment download-url --instance-id --file-id --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-id string 审批附件文件 ID (必填) + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只返回临时下载链接,不会自动保存文件。链接包含 OSS 签名参数,应在生成后立即使用;JSON 输出中的 `&` 是签名参数分隔符,复制链接时必须完整保留。附件来自审批评论时增加 `--with-comment-attachment`。 + +#### 批量授权下载审批钉盘文件 + +``` +Usage: + dws oa approval attachment authorize-download [flags] +Example: + dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]' --format json +Flags: + --file-infos string 文件信息 JSON 数组 (必填),每项包含数字类型 spaceId 和字符串类型 fileId,最多 10 项 +``` + +该命令为当前用户开通文件下载权限,但不返回下载链接。需要链接时继续调用 `attachment download-url`。 + +#### 批量授权预览审批附件 + +``` +Usage: + dws oa approval attachment authorize-preview [flags] +Example: + dws oa approval attachment authorize-preview --instance-id --file-ids , --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-ids strings 附件 ID 列表,逗号分隔 (必填),最多 20 项 + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -667,6 +718,9 @@ Flags: 用户说"待审批/待处理审批" → `approval list-pending` 用户说"审批详情/看审批" → `approval detail` +用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) +用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) +用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 61d7aecc..113beb05 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -91,3 +91,7 @@ dws drive +publish-unset --node 2. 分页、递归或过滤未完成时保留 cursor/truncated/errors,不包装成全量成功。 3. 写入超时或响应丢失先按 nodeId、名称、路径和大小回读,不能盲目重放。 4. 在线文档误入普通下载/覆盖时切 Doc、Sheet 或 AITable;不要用 Drive 重试改变内容。 + +## 辅助脚本 + +- [drive_tree_list.py](../scripts/drive_tree_list.py):递归列出钉盘目录树;普通浏览仍优先使用 `dws drive +list`。 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 4b5e6205..7124e064 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -31,6 +31,57 @@ Flags: --instance-id string 审批实例 ID (必填) ``` +### 审批附件授权与下载 + +先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: + +- 需要单个附件的临时下载链接:`attachment download-url` +- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` +- 需要在审批场景内批量预览附件:`attachment authorize-preview` + +#### 获取审批附件临时下载链接 + +``` +Usage: + dws oa approval attachment download-url [flags] +Example: + dws oa approval attachment download-url --instance-id --file-id --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-id string 审批附件文件 ID (必填) + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只返回临时下载链接,不会自动保存文件。链接包含 OSS 签名参数,应在生成后立即使用;JSON 输出中的 `&` 是签名参数分隔符,复制链接时必须完整保留。附件来自审批评论时增加 `--with-comment-attachment`。 + +#### 批量授权下载审批钉盘文件 + +``` +Usage: + dws oa approval attachment authorize-download [flags] +Example: + dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]' --format json +Flags: + --file-infos string 文件信息 JSON 数组 (必填),每项包含数字类型 spaceId 和字符串类型 fileId,最多 10 项 +``` + +该命令为当前用户开通文件下载权限,但不返回下载链接。需要链接时继续调用 `attachment download-url`。 + +#### 批量授权预览审批附件 + +``` +Usage: + dws oa approval attachment authorize-preview [flags] +Example: + dws oa approval attachment authorize-preview --instance-id --file-ids , --format json +Flags: + --instance-id string 审批实例 ID (必填) + --file-ids strings 附件 ID 列表,逗号分隔 (必填),最多 20 项 + --with-comment-attachment 是否包含评论中的附件 (可选,默认不包含) +``` + +该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -670,6 +721,9 @@ Flags: - 示例:"有没有外出申请的审批" → `approval list-pending --query 外出申请` - 示例:"待审批"(无关键词)→ `approval list-pending` 用户说"审批详情/看审批" → `approval detail` +用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) +用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) +用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go new file mode 100644 index 00000000..cd5f7cd7 --- /dev/null +++ b/test/unit/oa_attachment_delivery_policy_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package unit_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + "testing" +) + +func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller(0) failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + + attachmentFile := filepath.Join(root, "internal", "helpers", "oa_attachment.go") + if _, err := os.Stat(attachmentFile); !os.IsNotExist(err) { + t.Fatalf("OA attachment commands must live in internal/helpers/oa.go; separate file still exists: %s", attachmentFile) + } + + oaSourcePath := filepath.Join(root, "internal", "helpers", "oa.go") + oaFile, err := parser.ParseFile(token.NewFileSet(), oaSourcePath, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", oaSourcePath, err) + } + wantTools := map[string]string{ + "download-url": "get_attachment_download_url", + "authorize-download": "auth_download_file", + "authorize-preview": "auth_preview_attachment", + } + gotTools := make(map[string]string, len(wantTools)) + foundConstructor := false + for _, declaration := range oaFile.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != "newOAAttachmentCommand" { + continue + } + foundConstructor = true + ast.Inspect(function.Body, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || !isOAAttachmentLeafSpec(literal) { + return true + } + use := oaAttachmentLeafStringField(t, literal, "Use") + if _, expected := wantTools[use]; expected { + gotTools[use] = oaAttachmentLeafStringField(t, literal, "Tool") + } + return true + }) + } + if !foundConstructor { + t.Fatalf("%s missing newOAAttachmentCommand", oaSourcePath) + } + if !reflect.DeepEqual(gotTools, wantTools) { + t.Fatalf("%s attachment Use/Tool declarations = %#v, want %#v", oaSourcePath, gotTools, wantTools) + } + + docPaths := []string{ + filepath.Join(root, "skills", "mono", "references", "products", "oa.md"), + filepath.Join(root, "skills", "multi", "dingtalk-misc", "references", "oa.md"), + } + for _, docPath := range docPaths { + doc, err := os.ReadFile(docPath) + if err != nil { + t.Fatalf("read %s: %v", docPath, err) + } + for _, required := range []string{ + "dws oa approval attachment download-url", + "dws oa approval attachment authorize-download", + "dws oa approval attachment authorize-preview", + "临时下载链接", + "最多 10", + "最多 20", + } { + if !strings.Contains(string(doc), required) { + t.Errorf("%s missing OA attachment Skill guidance %q", docPath, required) + } + } + } +} + +func isOAAttachmentLeafSpec(literal *ast.CompositeLit) bool { + identifier, ok := literal.Type.(*ast.Ident) + return ok && identifier.Name == "LeafSpec" +} + +func oaAttachmentLeafStringField(t *testing.T, literal *ast.CompositeLit, fieldName string) string { + t.Helper() + for _, element := range literal.Elts { + keyValue, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + key, ok := keyValue.Key.(*ast.Ident) + if !ok || key.Name != fieldName { + continue + } + value, ok := keyValue.Value.(*ast.BasicLit) + if !ok || value.Kind != token.STRING { + t.Fatalf("LeafSpec.%s must be a string literal", fieldName) + } + decoded, err := strconv.Unquote(value.Value) + if err != nil { + t.Fatalf("decode LeafSpec.%s: %v", fieldName, err) + } + return decoded + } + return "" +}