package handler // 严格解码的回归测试。 // // 事故背景(生产实测):homeagent 插件的 send_mail 传的是 // `attachments: [{"attachment_id": …}]`,而服务端要的是 `attachment_ids: ["…"]`。 // 宽容解码让这变成一种**静默成功**: // // POST /mail/send {…,"attachments":[{"attachment_id":"598f100e…"}]} // → HTTP 200 {"mail_id":"2a64fdc8…"} // → SELECT COUNT(*) FROM attachments WHERE mail_id='2a64fdc8…' → 0 // // 邮件发出去了、附件一个都没带、没有任何一层报错。那个 bug 活了很久, // 正因为没人会去核对一个返回 200 的请求。 import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // 这就是那次事故的最小复现:把 attachment_ids 写成 attachments。 func TestStrictDecodeRejectsMisspelledField(t *testing.T) { type sendReq struct { To string `json:"to"` Subject string `json:"subject"` AttachmentIDs []string `json:"attachment_ids"` } r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader( `{"to":"jianf","subject":"x","attachments":[{"attachment_id":"598f100e"}]}`)) w := httptest.NewRecorder() var req sendReq if DecodeBody(w, r, &req) { t.Fatal("拼错的字段名必须被拒绝 —— 否则又是一次静默成功") } if w.Code != http.StatusBadRequest { t.Fatalf("状态码应为 400,实际 %d", w.Code) } var resp map[string]string if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("响应不是 JSON: %v", err) } msg := resp["error"] // 报出错的字段名 if !strings.Contains(msg, "attachments") { t.Errorf("信息里应指出 attachments,实际 %q", msg) } // **并且**列出对的拼法 —— 少了这半句,调用方仍要去翻服务端源码, // 而拼错字段名恰恰是最容易犯、最难自查的错 if !strings.Contains(msg, "attachment_ids") { t.Errorf("信息里应列出正确字段 attachment_ids,实际 %q", msg) } } // 负向对照:合法字段必须原样通过,不能被严格解码误伤。 func TestStrictDecodeAcceptsCorrectField(t *testing.T) { type sendReq struct { To string `json:"to"` AttachmentIDs []string `json:"attachment_ids"` } r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader( `{"to":"jianf","attachment_ids":["a","b"]}`)) w := httptest.NewRecorder() var req sendReq if !DecodeBody(w, r, &req) { t.Fatalf("合法请求体被拒了:%s", w.Body.String()) } if w.Code != http.StatusOK { // recorder 默认 200,即「没写过响应」 t.Fatalf("不该写任何响应,实际状态码 %d", w.Code) } if len(req.AttachmentIDs) != 2 { t.Fatalf("附件 id 应解出 2 个,实际 %#v", req.AttachmentIDs) } } // 省略可选字段仍然合法 —— 严格针对的是「多」而不是「少」。 func TestStrictDecodeAllowsOmittedFields(t *testing.T) { type sendReq struct { To string `json:"to"` CC string `json:"cc"` AttachmentIDs []string `json:"attachment_ids"` } r := httptest.NewRequest(http.MethodPost, "/mail/send", strings.NewReader(`{"to":"jianf"}`)) w := httptest.NewRecorder() var req sendReq if !DecodeBody(w, r, &req) { t.Fatalf("省略可选字段被拒了:%s", w.Body.String()) } } // ─── DecodeLenient(心跳唯一的例外)─── func TestDecodeLenientKeepsKnownFieldsAndReportsUnknown(t *testing.T) { type hb struct { Models []string `json:"models"` ModeEnforcement string `json:"mode_enforcement"` } r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader( `{"models":["a"],"mode_enforcement":"native","futureField":1}`)) var req hb unknown, err := DecodeLenient(r, &req) if err != nil { t.Fatalf("心跳体不该整体作废: %v", err) } // 已知字段必须照常取到 —— 这正是心跳要宽容的理由: // 插件比服务端新时,代价不该是会话快照与模型目录一起丢掉 if len(req.Models) != 1 || req.ModeEnforcement != "native" { t.Fatalf("已知字段应正常解析,实际 %#v", req) } // 但**必须报出来**,否则又是一次静默忽略 if len(unknown) != 1 || unknown[0] != "futureField" { t.Fatalf("未知字段应报 [futureField],实际 %#v", unknown) } } // 多个未知字段要全报出来。 // // json 每遇到一个未知字段就立刻返回,所以实现里必须循环剥 —— // 不循环的话「多带了三个字段」只会报出第一个,而人改完那一个又撞上下一个。 func TestDecodeLenientReportsAllUnknownFields(t *testing.T) { type hb struct { Models []string `json:"models"` } r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader( `{"models":[],"aaa":1,"bbb":2,"ccc":3}`)) var req hb unknown, err := DecodeLenient(r, &req) if err != nil { t.Fatalf("不该报错: %v", err) } if len(unknown) != 3 { t.Fatalf("应报出 3 个未知字段,实际 %#v", unknown) } got := map[string]bool{} for _, u := range unknown { got[u] = true } for _, want := range []string{"aaa", "bbb", "ccc"} { if !got[want] { t.Errorf("未报出 %q(实际 %#v)", want, unknown) } } } func TestDecodeLenientCleanBodyReportsNothing(t *testing.T) { type hb struct { Models []string `json:"models"` } r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":["a"]}`)) var req hb unknown, err := DecodeLenient(r, &req) if err != nil { t.Fatalf("不该报错: %v", err) } // 正常心跳的响应里不该多一个空数组 —— 调用方据此决定是否带 unknown_fields if len(unknown) != 0 { t.Fatalf("干净的体不该报未知字段,实际 %#v", unknown) } } func TestDecodeLenientEmptyBody(t *testing.T) { type hb struct { Models []string `json:"models"` } r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(``)) var req hb unknown, err := DecodeLenient(r, &req) if err != nil { t.Fatalf("空体应静默通过(心跳可以不带 body): %v", err) } if len(unknown) != 0 { t.Fatalf("空体不该报未知字段,实际 %#v", unknown) } } // 语法错误仍然要报 —— 宽容的是「多字段」,不是「烂 JSON」。 func TestDecodeLenientStillRejectsMalformedJSON(t *testing.T) { type hb struct { Models []string `json:"models"` } r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":`)) var req hb if _, err := DecodeLenient(r, &req); err == nil { t.Fatal("截断的 JSON 必须报错") } } // 类型不对也要报:`models` 要的是数组,给字符串说明插件写错了结构, // 那不是「服务端还不认识的新字段」。 func TestDecodeLenientStillRejectsWrongType(t *testing.T) { type hb struct { Models []string `json:"models"` } r := httptest.NewRequest(http.MethodPost, "/agent/heartbeat", strings.NewReader(`{"models":"oops"}`)) var req hb if _, err := DecodeLenient(r, &req); err == nil { t.Fatal("类型不匹配必须报错") } } // ─── jsonFieldNames ─── func TestJSONFieldNamesListsAcceptedKeys(t *testing.T) { type req struct { To string `json:"to"` AttachmentIDs []string `json:"attachment_ids"` Skipped string `json:"-"` NoTag string unexported string //nolint:unused // 刻意保留:验证非导出字段不进清单 } names := jsonFieldNames(&req{}) joined := strings.Join(names, ",") for _, want := range []string{"to", "attachment_ids"} { if !strings.Contains(joined, want) { t.Errorf("应含 %q,实际 %q", want, joined) } } // json:"-" 的字段不该出现在「本端点接受」的清单里 —— 它确实不接受 if strings.Contains(joined, "Skipped") || strings.Contains(joined, "-") { t.Errorf("json:\"-\" 的字段不该列出,实际 %q", joined) } // 无 tag 时用字段名(json 包也是这么匹配的) if !strings.Contains(joined, "NoTag") { t.Errorf("无 tag 字段应按字段名列出,实际 %q", joined) } // 非导出字段 json 根本不看 if strings.Contains(joined, "unexported") { t.Errorf("非导出字段不该列出,实际 %q", joined) } }