Files
MailUI4Agents/server/internal/handler/strictdecode_test.go

248 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}