Files
Cyrene_v2/internal/services/gateway/rest_chat_image_test.go
T
AskaEth f5cf117482
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(gateway): 图片上限单一真源 —— 以 vision 硬顶为准,配置只能收紧(消除「网关照放、vision 拒」)
- 原状:网关与 vision 各有一份同值常量,且网关上限可由 CHAT_IMAGE_MAX_BYTES 调大 ⇒ 调大后
  **网关照放、vision 拒绝**(ErrImageTooLarge),base64/URL 路径上「调大配置」失效
- 修法:网关改为**引用 vision 的常量**(`vision.MaxInlineImageBytes` / `InlineImagePrefixAllowance` /
  `MaxInlineImageChars`,import 方向与既有的 voice 同层、不违规);`chatImageLimitBytes` 语义=
  「configured 生效但**钳在 vision 硬顶**」(只收紧不放宽);默认口径直接用 vision 常量(不再自算)
- 复核:gateway 包测试绿(9 条 ChatImage,含默认逐字节同值、配 1 MiB 生效、配 8 MiB 被钳到硬顶且
  同一串在 vision 侧也 ErrImageTooLarge ⇒ 两侧同一答案);**变异两点**(去掉钳位分支 → 断言红;
  再清空解析循环 → 实测复现「配 2× 硬顶时网关 200 放行」)均已还原
- 登记:真要支持 > 5 MiB 需给 vision 可注入上限(并把 /upload 的 5 MiB 一起改);`.env.example`/`config.go`
  注释/`api-reference` 仍写「上限=CHAT_IMAGE_MAX_BYTES」未提钳位,待同步
2026-10-04 16:27:35 +08:00

380 lines
17 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"
"reflect"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
)
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
// 上限(默认与 POST /api/v1/upload 的 5 MiB 同口径 —— 也就是 vision 解码层的
// 硬顶,可用 CHAT_IMAGE_MAX_BYTES 配置收紧;≤0 = 用默认),超限回 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 返回一个长度恰为「默认上限换算出的字符数 + 1」的 image 串(内容
// 本身是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。
func oversizeImage() string {
return strings.Repeat("A", chatImageCharsFor(maxChatImageBytes)+1)
}
// TestChatImageDefaultLimitMatchesVisionHardCap 钉住「默认口径 = vision 解码层硬顶」
// 这条单一真源:默认上限、前缀余量与字符换算结果都必须与
// internal/runtime/multimodal/vision 的权威常量逐字节相等,且未配置(0 / 负数)时
// 落在同一个数上 —— 默认行为与引入该配置之前逐字节一致,同时两处口径不可能漂移。
func TestChatImageDefaultLimitMatchesVisionHardCap(t *testing.T) {
t.Parallel()
if maxChatImageBytes != vision.MaxInlineImageBytes {
t.Fatalf("默认上限 = %d, want vision 硬顶 %d", maxChatImageBytes, vision.MaxInlineImageBytes)
}
if chatImagePrefixAllowance != vision.InlineImagePrefixAllowance {
t.Fatalf("前缀余量 = %d, want %d", chatImagePrefixAllowance, vision.InlineImagePrefixAllowance)
}
if got := chatImageCharsFor(maxChatImageBytes); got != vision.MaxInlineImageChars {
t.Fatalf("默认字符上限 = %d, want vision.MaxInlineImageChars %d", got, vision.MaxInlineImageChars)
}
for _, configured := range []int{0, -1, -1 << 20} {
if got := chatImageLimitBytes(configured); got != vision.MaxInlineImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 默认(= vision 硬顶)%d",
configured, got, vision.MaxInlineImageBytes)
}
}
}
// TestChatImageConfiguredLimitClampedToVisionHardCap 钉住「配置只能收紧上限」:
// CHAT_IMAGE_MAX_BYTES 配成硬顶的 2 倍时,生效上限仍是 vision 硬顶(比硬顶只小一格的
// 图照样通过,多一个字符就被拒),且解码层对同一串给出的裁决一致 —— base64/URL 路径上
// 不再出现「网关照放、vision 拒」的自相矛盾。
// 变异反证:去掉 chatImageLimitBytes 的钳位,第二条断言里的超硬顶载荷会被放行(200)
// 并驱动 runner,本用例立刻红。
func TestChatImageConfiguredLimitClampedToVisionHardCap(t *testing.T) {
t.Parallel()
// 解析口径:配得比硬顶大(大 1 字节、2 倍、1 GiB)一律回到硬顶。
for _, configured := range []int{vision.MaxInlineImageBytes + 1, 2 * vision.MaxInlineImageBytes, 1 << 30} {
if got := chatImageLimitBytes(configured); got != vision.MaxInlineImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 硬顶 %d(配置不得放宽上限)",
configured, got, vision.MaxInlineImageBytes)
}
}
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
env.srv.cfg.Server.ChatImageMaxBytes = 2 * vision.MaxInlineImageBytes
tok := env.token(t, auth.RoleOwner)
const prefix = "data:image/png;base64,"
// 比硬顶多一个字符:网关必须按硬顶拒掉它,而不是按配置放行。
over := prefix + strings.Repeat("A", vision.MaxInlineImageChars+1-len(prefix))
if len(over) != vision.MaxInlineImageChars+1 {
t.Fatalf("构造长度 = %d, want %d", len(over), vision.MaxInlineImageChars+1)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": over}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("配到 2× 硬顶时仍须按硬顶拒,status = %d (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("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
if _, _, err := vision.Resolve(context.Background(),
vision.ImageRef{Source: vision.SourceBase64, Data: over}, nil); !errors.Is(err, vision.ErrImageTooLarge) {
t.Fatalf("解码层对同一串的裁决 = %v, want ErrImageTooLarge(网关口径必须与解码层一致)", err)
}
// 恰好等于硬顶的图照常通过:钳位不得顺手把上限再收紧一格。
atCap := prefix + strings.Repeat("A", vision.MaxInlineImageChars-len(prefix))
if len(atCap) != vision.MaxInlineImageChars {
t.Fatalf("构造长度 = %d, want %d", len(atCap), vision.MaxInlineImageChars)
}
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atCap}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("恰好等于硬顶的图必须通过,status = %d (body=%s)", resp.StatusCode, body)
}
if got := runner.images(); len(got) != 1 || got[0] != atCap {
t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got))
}
}
// 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)
}
// 配置的默认值(env 标签)必须就是 5 MiB:进程环境与 .env 都没给时,网关
// 拿到的正是这个数。这里直接读标签而不是去 Load 配置,是为了不受运行环境
// 里可能存在的同名环境变量影响(那反而会让断言失真)。
field, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName("ChatImageMaxBytes")
if !ok {
t.Fatal("config.ServerConfig 缺 ChatImageMaxBytes 字段(配置契约已断)")
}
if got := field.Tag.Get("default"); got != strconv.Itoa(maxChatImageBytes) {
t.Fatalf("CHAT_IMAGE_MAX_BYTES 的默认值 = %q, want %d", got, maxChatImageBytes)
}
// 未配置(零值)/ 0 / 负数一律解析为默认值 —— 默认行为与引入该配置之前逐字节一致。
for _, configured := range []int{0, -1, -1 << 20} {
if got := chatImageLimitBytes(configured); got != maxChatImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 默认 %d", configured, got, maxChatImageBytes)
}
}
chars := chatImageCharsFor(chatImageLimitBytes(0))
// 上限必须装得下一张满额图:base64 长度对应的解码字节数 ≥ 预算。
if got := chars / 4 * 3; got < maxChatImageBytes {
t.Fatalf("字符上限只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes)
}
// 余量只能是 data: 前缀的余量,不能顺带塞进一整张图。
if chars > ((maxChatImageBytes+2)/3)*4+4096 {
t.Fatalf("字符上限 = %d 的余量过大(前缀余量应 ≤4096)", chars)
}
}
// 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)
limit := chatImageCharsFor(maxChatImageBytes)
image := "data:image/png;base64," + strings.Repeat("A", limit-len("data:image/png;base64,"))
if len(image) != limit {
t.Fatalf("构造长度 = %d, want %d", len(image), limit)
}
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))
}
}
// TestChatImageConfiguredLimitTaken 钉住「配置真的生效」(G3 遗留:上限不再硬编码):
// 把 CHAT_IMAGE_MAX_BYTES 配成 1 MiB 后,恰好 1 MiB 的图仍然通过,而只多一个字符就
// 被 400 image_too_large 拒绝 —— 既证明配置走的是同一条入口校验,也证明上限确实变小了
// (第二个断言若仍按 5 MiB 算,会因远未到默认上限而被放行、测试即红)。
func TestChatImageConfiguredLimitTaken(t *testing.T) {
t.Parallel()
const configured = 1 << 20
limit := chatImageCharsFor(configured)
if limit >= chatImageCharsFor(maxChatImageBytes) {
t.Fatal("1 MiB 的字符上限不应 ≥ 默认 5 MiB 的上限(本用例失去了区分力)")
}
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
// 上限在每次请求时从配置读取,因此这里直接钉住该环境的配置值。
env.srv.cfg.Server.ChatImageMaxBytes = configured
tok := env.token(t, auth.RoleOwner)
atLimit := "data:image/png;base64," + strings.Repeat("A", limit-len("data:image/png;base64,"))
if len(atLimit) != limit {
t.Fatalf("构造长度 = %d, want %d", len(atLimit), limit)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atLimit}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("恰好 1 MiB 的图必须通过,status = %d (body=%s)", resp.StatusCode, body)
}
// 多一个字符即超限:默认 5 MiB 口径下它会通过,因此这条断言恰好区分了配置是否生效。
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atLimit + "A"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("超过配置上限一个字符必须 400,status = %d (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) != 1 {
t.Fatalf("turn 数 = %d, want 1", len(runner.calls))
}
}
// TestChatImageNonPositiveConfigUsesDefault 在 HTTP 入口钉住「0 / 负数 = 用默认」的语义:
// 配置成 0(或负数)时,超出**默认** 5 MiB 上限一个字符的图仍被拒 —— 上限没有被解成
// 「无限」,默认行为与引入该配置之前逐字节一致。
func TestChatImageNonPositiveConfigUsesDefault(t *testing.T) {
t.Parallel()
for _, configured := range []int{0, -1} {
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
env.srv.cfg.Server.ChatImageMaxBytes = configured
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("配置 %d 时超出默认上限的图必须 400,status = %d (body=%s)", configured, 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("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
}
// 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
}