From 6a2e9dd10e867977846bf1ef3e37febeda2b89f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=AD=E9=80=B8?= Date: Fri, 14 Aug 2026 17:41:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AE=A1=E6=89=B9=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E7=9B=B8=E5=85=B3dws=EF=BC=8C=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E3=80=81=E4=B8=8B=E8=BD=BD=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E3=80=81=E8=8E=B7=E5=8F=96=E4=B8=8B=E8=BD=BD=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=20to=20#666?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...a-attachment-download-url-output-design.md | 55 ++++ .../app/schema_oa_attachment_contract_test.go | 103 +++++++ internal/helpers/oa.go | 9 +- internal/helpers/oa_attachment.go | 268 ++++++++++++++++++ internal/helpers/oa_attachment_test.go | 239 ++++++++++++++++ 5 files changed, 671 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-14-oa-attachment-download-url-output-design.md create mode 100644 internal/app/schema_oa_attachment_contract_test.go create mode 100644 internal/helpers/oa_attachment.go create mode 100644 internal/helpers/oa_attachment_test.go 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..82d48f04 --- /dev/null +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) { + tests := []struct { + cliPath string + canonical string + rpc string + description string + effect string + }{ + { + cliPath: "oa approval attachment download-url", + canonical: "oa.get_attachment_download_url", + rpc: "get_attachment_download_url", + description: "获取审批附件下载授权并生成临时下载链接", + effect: "read", + }, + { + cliPath: "oa approval attachment authorize-download", + canonical: "oa.auth_download_file", + rpc: "auth_download_file", + description: "批量授权当前用户下载指定的审批钉盘文件", + effect: "write", + }, + { + cliPath: "oa approval attachment authorize-preview", + canonical: "oa.auth_preview_attachment", + rpc: "auth_preview_attachment", + description: "批量授权当前用户预览审批单中的附件", + effect: "write", + }, + } + + root := NewRootCommand() + for _, test := range tests { + t.Run(test.rpc, func(t *testing.T) { + 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) + } + if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" { + t.Fatal(problem) + } + }) + } +} diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 87413498..3f57cc81 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -49,7 +49,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 +59,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 +72,7 @@ func newOaCommand() *cobra.Command { root := &cobra.Command{ Use: "oa", Short: "OA 审批 / 同意 / 拒绝 / 撤销", - Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`, + Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`, RunE: groupRunE, } @@ -1376,6 +1378,7 @@ func newOaCommand() *cobra.Command { approvalForecastCmd, approvalCreateCmd, ) + approvalCmd.AddCommand(newOAAttachmentCommand()) root.AddCommand(approvalCmd) return root diff --git a/internal/helpers/oa_attachment.go b/internal/helpers/oa_attachment.go new file mode 100644 index 00000000..6994d2d6 --- /dev/null +++ b/internal/helpers/oa_attachment.go @@ -0,0 +1,268 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var items []map[string]json.RawMessage + 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) + } + } + + spaceRaw, ok := item["spaceId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1) + } + spaceDecoder := json.NewDecoder(strings.NewReader(string(spaceRaw))) + spaceDecoder.UseNumber() + var spaceValue any + if err := spaceDecoder.Decode(&spaceValue); err != nil { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 不是有效数字: %w", index+1, err) + } + spaceID, ok := spaceValue.(json.Number) + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1) + } + + fileRaw, ok := item["fileId"] + if !ok { + return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1) + } + var fileID string + if err := json.Unmarshal(fileRaw, &fileID); err != nil { + return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串: %w", index+1, err) + } + 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 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", + Call: func(_ *cobra.Command, tool string, args map[string]any) error { + jsonOutput := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") + return callMCPToolInternalOpts("oa", tool, args, jsonOutput) + }, + 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: "获取审批附件下载授权并生成临时下载链接", + 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", + 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: "批量授权当前用户下载指定的审批钉盘文件", + 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", + 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: "批量授权当前用户预览审批单中的附件", + 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 +} diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go new file mode 100644 index 00000000..b17034ef --- /dev/null +++ b/internal/helpers/oa_attachment_test.go @@ -0,0 +1,239 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "bytes" + "encoding/json" + "io" + "os" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +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.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) + } + }) + + 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) + } + if stdout != response+"\n" { + t.Fatalf("stdout = %q, want %q", stdout, response+"\n") + } + }) +} + +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{} + if err := executeOACommand(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: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`}, + {name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`}, + {name: "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) + } + }) + } +}