Files
Cyrene_v2/internal/services/gateway/rest_chat_image_test.go
T
AskaEth 3638ed7708
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway): chat 的 image 加上限并显式报错(G3,审计协同 §G)
- 原状:image(base64/URL 字符串)在 API 层**无长度上限**、原样落库 ⇒ 一张 5 MiB 的图 ≈ 每页历史重传 6.7 MB
- 修法:入口层(handleChat,位于 stream 分叉**之前** ⇒ 同步与 stream:true 两条路径同拦)按**字符串长度零分配**判定,
  超限 → 400 `image_too_large`,文案给出上限并**指路 POST /api/v1/upload**;日志只记 session/chars/limit(不打图片内容)
- 上限口径:**与 POST /upload 的 maxUploadBytes = 5 MiB 取同一个答案**(否则内联 base64 可绕开上传限制);
  字符上限 = ceil(n/3)*4 + 64(覆盖 data URI 前缀),**恰好 5 MiB 的整幅图编码后 = 6,990,508 字符,绝不误拒**
- 复核:6 条用例(上限与 upload 同值、边界 ==limit 通过 / +1 拒、超限**0 个回合被驱动**、
  stream:true 同样被拒、恰好等于上限逐字节到达 runner、小图行为逐字节不变);
  **2 条变异反证**(忘接守卫 / 边界改 `<`)均已还原
- 实测附注:WS 侧今天**已被传输层关死**(wsReadLimit=4096,8KB chat 帧直接断连)⇒ 多 MB base64 无法经 WS 进入;
  若日后放宽该上限,必须在 ws 入口补同一个校验(回报里给了精确落点)
- 未做(登记):上限暂不可配置(等 config.go 空闲)、REST 请求体仍整体读入内存(MaxBytesReader 属行为变更)、
  vision 层 base64 路径无长度预检(纵深防御)、历史 base64 行需离线批处理(方案已出)
2026-10-04 14:21:12 +08:00

211 lines
8.4 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 gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
// 上限(与 POST /api/v1/upload 的 5 MiB 同口径),超限回 400 +
// image_too_large 的明确错误,而不是把几 MB 的 base64 原样吃下、落库、
// 再被每页历史重传一遍。
// chatImageRecordingRunner 记录每次 Run 看到的完整消息(不只是 sessionID),
// 因此既能断言「超限的轮次从未被驱动」,也能逐字节比对正常小图的图片载荷。
type chatImageRecordingRunner struct {
calls [][]model.Message
res *loop.Result
}
func (r *chatImageRecordingRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
r.calls = append(r.calls, msgs)
if r.res != nil {
return r.res, nil
}
return &loop.Result{Text: "收到", Steps: 1, StopReason: "stop"}, nil
}
// images 返回每次 Run 中首个图片内容的 ImageURL。
func (r *chatImageRecordingRunner) images() []string {
out := make([]string, 0, len(r.calls))
for _, msgs := range r.calls {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeImage {
out = append(out, c.ImageURL)
}
}
}
}
return out
}
// oversizeImage 返回一个长度恰为 maxChatImageChars+1 的 image 串(内容本身
// 是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。
func oversizeImage() string {
return strings.Repeat("A", maxChatImageChars+1)
}
// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:它必须与
// POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成
// base64 字符串长度后既**装得下**一张满额的 5 MiB 图,也不留下荒谬的余量。
// 这条不变量让「有人改了上限或改歪了膨胀系数」在测试里立刻暴露(反证)。
func TestChatImageLimitMatchesUploadBudget(t *testing.T) {
t.Parallel()
if maxChatImageBytes != maxUploadBytes {
t.Fatalf("maxChatImageBytes = %d, want %d (与 POST /upload 同口径)", maxChatImageBytes, maxUploadBytes)
}
// 上限必须装得下一张满额图:base64 长度对应的解码字节数 ≥ 预算。
if got := maxChatImageChars / 4 * 3; got < maxChatImageBytes {
t.Fatalf("maxChatImageChars 只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes)
}
// 余量只能是 data: 前缀的余量,不能顺带塞进一整张图。
if maxChatImageChars > ((maxChatImageBytes+2)/3)*4+4096 {
t.Fatalf("maxChatImageChars = %d 的余量过大(前缀余量应 ≤4096)", maxChatImageChars)
}
}
// TestChatImageSizeBoundary 用极小上限直接钉住比较运算符与错误载荷:恰好
// 等于上限通过,多一个字符就 400 + image_too_large。若判定被改成 >= / <
// (即有人「顺手」把边界挪一位),这里立刻红。
func TestChatImageSizeBoundary(t *testing.T) {
t.Parallel()
const limit = 8
if err := checkChatImageSize(strings.Repeat("A", limit), limit); err != nil {
t.Fatalf("恰好等于上限必须通过,得到 %v", err)
}
err := checkChatImageSize(strings.Repeat("A", limit+1), limit)
if err == nil {
t.Fatal("超过上限一个字符必须报错")
}
if !errors.Is(err, apperr.ErrBadRequest) {
t.Fatalf("错误必须 wrap ErrBadRequest(400),得到 %v", err)
}
var target *apperr.AppError
if !errors.As(err, &target) || target.Code != chatImageTooLargeCode {
t.Fatalf("错误码必须为 %q,得到 %v", chatImageTooLargeCode, err)
}
if got := apperr.HTTPStatus(err); got != http.StatusBadRequest {
t.Fatalf("HTTP 状态 = %d, want 400", got)
}
}
// TestChatImageTooLargeRejected 是主用例:超限的 image 必须 400 +
// image_too_large,且**从未驱动过任何回合** —— 也就不会进模型、不会落库、
// 不会被历史回放重传。变异反证:把入口的校验删掉,这条测试会因为 turn 被
// 驱动而红。
func TestChatImageTooLargeRejected(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage()}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
code, msg := chatImageErrorOf(t, body)
if code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if !strings.Contains(msg, "upload") || !strings.Contains(msg, "/api/v1/upload") {
t.Fatalf("错误文案必须指路 POST /api/v1/upload,得到 %q", msg)
}
if strings.Contains(msg, "AAAA") {
t.Fatalf("错误文案不得回显图片内容:%q", msg)
}
if len(runner.calls) != 0 {
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
// TestChatImageTooLargeRejectedOnStreamPath 覆盖 REST 的 stream 分支:它会把
// 该轮转发给 ws.chat,转发前必须同样被拒(否则流式成为绕开上限的后门)。
func TestChatImageTooLargeRejectedOnStreamPath(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage(), "stream": true}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if len(runner.calls) != 0 {
t.Fatalf("超限的 stream 请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
// TestChatImageAtLimitAccepted 是边界:长度恰好等于上限的 image 照常通过
// (合法客户端在 5 MiB 整幅图上绝不能吃到误拒)。
func TestChatImageAtLimitAccepted(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
image := "data:image/png;base64," + strings.Repeat("A", maxChatImageChars-len("data:image/png;base64,"))
if len(image) != maxChatImageChars {
t.Fatalf("构造长度 = %d, want %d", len(image), maxChatImageChars)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": image}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := runner.images(); len(got) != 1 || got[0] != image {
t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got))
}
}
// TestChatSmallImageUnchanged 锁定「正常小图行为与今天逐字节一致」:小
// data: URI 原样进入图片内容部分,文本与图片两部分都在。
func TestChatSmallImageUnchanged(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
image := "data:image/png;base64,iVBORw0KGgo="
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "text": "看看这张", "image": image}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(runner.calls) != 1 {
t.Fatalf("turn 数 = %d, want 1", len(runner.calls))
}
msgs := runner.calls[0]
if len(msgs) != 1 {
t.Fatalf("消息数 = %d, want 1", len(msgs))
}
if len(msgs[0].Content) != 2 || msgs[0].Content[0].Text != "看看这张" ||
msgs[0].Content[1].Type != model.ContentTypeImage || msgs[0].Content[1].ImageURL != image {
t.Fatalf("图片回合内容变了:%+v", msgs[0].Content)
}
}
// chatImageErrorOf 取出 {error:{code,message}} 的 code 与 message。
func chatImageErrorOf(t *testing.T, body []byte) (code, message string) {
t.Helper()
env := decodeBody(t, body)
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(env["error"], &eobj); err != nil {
t.Fatalf("error envelope: %v (body=%s)", err, body)
}
return eobj.Code, eobj.Message
}