fix(oa): validate commit response required fields before reporting success to #666

- 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段
- commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果
- 补充 malformed commit 响应回归测试,覆盖率 100%
This commit is contained in:
昭逸
2026-08-21 10:40:46 +08:00
parent dc37dd2c34
commit 4aef07ccd3
2 changed files with 334 additions and 3 deletions
+61 -3
View File
@@ -153,6 +153,56 @@ func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string
return output.Success(result), nil
}
// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result
// 包含构造 DDAttachment 所需的全部必需字段。当字段缺失或类型错误时返回 Validation 错误,
// 避免 Agent 拿到空 fileId 后组装无效的审批表单。
func validateOAAttachmentCommitResult(result any) error {
resultMap, ok := result.(map[string]any)
if !ok || resultMap == nil {
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象")
}
// spaceId — 接受 string 或 number
switch v := resultMap["spaceId"].(type) {
case string:
if strings.TrimSpace(v) == "" {
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
case float64:
// numeric spaceId is acceptable
case json.Number:
// numeric spaceId is acceptable
default:
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
// fileName — must be non-empty string
if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" {
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName")
}
// fileSize — must be > 0 (number)
switch v := resultMap["fileSize"].(type) {
case float64:
if v <= 0 {
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
case json.Number:
if f, err := v.Float64(); err != nil || f <= 0 {
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
default:
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize")
}
// fileId — must be non-empty string
if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" {
return apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId")
}
return nil
}
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
@@ -285,8 +335,8 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
return err
}
// Step 3: 提交上传信息完成入库,并以统一输出渲染 commit 结果。
result, err := callOAAttachmentResultCtx(ctx, "commit_attachment_upload_info", map[string]any{
// Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。
commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{
"fileName": fileName,
"uploadKey": uploadKey,
"fileSize": float64(fileSize),
@@ -294,7 +344,15 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), result)
commitResp, ok := commitData.(map[string]any)
if !ok {
return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象")
}
commitResult, _ := commitResp["result"]
if err := validateOAAttachmentCommitResult(commitResult); err != nil {
return err
}
return output.StoreResult(cmd.Context(), output.Success(commitResult))
}
func newOAAttachmentCommand() *cobra.Command {
+273
View File
@@ -737,6 +737,279 @@ func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
// 缺少必需字段时命令报错(而不是包装为成功)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantErr string
}{
{
name: "result is null",
commitResponse: `{"success":true,"result":null}`,
wantErr: "不是有效的 JSON 对象",
},
{
name: "result is empty object",
commitResponse: `{"success":true,"result":{}}`,
wantErr: "spaceId",
},
{
name: "missing fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
wantErr: "fileId",
},
{
name: "empty fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
wantErr: "fileId",
},
{
name: "missing spaceId",
commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
wantErr: "spaceId",
},
{
name: "fileSize is zero",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
wantErr: "fileSize",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-validate.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
// init + commit = 2 MCP calls
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
// PUT should have been called (init succeeded)
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
// json.Number 类型分支。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
tests := []struct {
name string
input any
wantErr string // empty means expect nil error
}{
// result 不是 map
{name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
{name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
{name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
{name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
// spaceId — 空字符串
{name: "spaceId empty string", input: map[string]any{
"spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
{name: "spaceId whitespace only", input: map[string]any{
"spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// spaceId — json.Number(通过 UseNumber 解码的数字)
{name: "spaceId json.Number", input: map[string]any{
"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — float64(正常路径)
{name: "spaceId float64", input: map[string]any{
"spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — 非法类型
{name: "spaceId bool", input: map[string]any{
"spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// fileName — 非 string 类型
{name: "fileName is number", input: map[string]any{
"spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName is nil", input: map[string]any{
"spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName empty", input: map[string]any{
"spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
// fileSize — json.Number 有效
{name: "fileSize json.Number valid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
}, wantErr: ""},
// fileSize — json.Number 无效(<= 0)
{name: "fileSize json.Number zero", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number negative", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number invalid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
}, wantErr: "fileSize"},
// fileSize — 非法类型(default 分支)
{name: "fileSize is string", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize is nil", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
}, wantErr: "fileSize"},
// fileId — 非 string 类型
{name: "fileId is number", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
}, wantErr: "fileId"},
{name: "fileId whitespace", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
}, wantErr: "fileId"},
// 全部通过
{name: "all valid string types", input: map[string]any{
"spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
}, wantErr: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateOAAttachmentCommitResult(test.input)
if test.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", "",
)
if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("error = %v, want --file empty error", err)
}
if caller.calls != 0 {
t.Fatalf("MCP calls = %d, want 0", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{err: errors.New("injected init failure")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "init-err.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatal("expected error from init call failure, got nil")
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
// 中 commitData 不是 map[string]any 的分支(line 348)。
func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 返回一个 JSON 字符串而非对象
{text: `"not an object"`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-nonobj.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
t.Fatalf("error = %v, want commit non-object error", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{not valid json`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "bad-init.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
t.Fatalf("error = %v, want parse failure", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。