248 lines
8.1 KiB
Go
248 lines
8.1 KiB
Go
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)
|
||
}
|
||
}
|