Files
Cyrene_v2/internal/services/gateway/documents_test.go
T
AskaEth 407c934a05 feat(gateway): 文档存储端点 POST/GET /api/v1/documents(design/24 S1)
- 字节嗅探唯一裁决 + 扩展名白名单 deny-by-default(23 个文本/代码扩展名);
  客户端 Content-Type 与文件名只作参考、可被否决。
- 内容寻址落盘名 sha256[:32]+ext ⇒ 同字节同扩展名天然去重;首次 201/deduped:false,
  命中 200/同一 name/deduped:true。
- 独立限额 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB,与图片 5 MiB 刻意解耦)+ MaxBytesReader。
- 读侧 Bearer owner|admin + Content-Disposition: attachment + nosniff(与图片读侧刻意不同);
  写侧审计 document.upload;错误码 unsupported_media_type/document_too_large/document_not_found。
- 元数据表 documents(0014,含 idx_documents_name 唯一、idx_documents_sha256);
  guardDocumentPaths 防路径穿越(否则 ServeMux 会 307 泄漏路径)。
- 既有 /api/v1/upload 与 /api/v1/files/{name} 零改动(upload.go 零 diff)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、9 条新单测 PASS。
变异独立复现 3 条:白名单加 ".js" ⇒ 415 用例红;嗅探裁决一律放行 ⇒ 谎报类型用例红;
摘掉 guardDocumentPaths ⇒ 穿越用例红(307 重定向,正是那道防护存在的理由)。
2026-10-04 17:07:20 +08:00

498 lines
20 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
// 本文件锁定切片 S1(design/24 §3 / §6.1)的存储端点口径:字节嗅探是唯一类型裁决、
// 扩展名白名单 deny-by-default、内容寻址去重、独立限额、读侧 Bearer + 不可渲染、
// 路径穿越统一 404、元数据落表。图片链路的既有行为不在断言范围内。
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// documentsEnv 打包文档端点测试所需的依赖:真实 JWT 服务、临时数据目录,以及
// 一个**已跑完全部迁移的临时 SQLite 库**(绝不触碰 data/cyrene.db)。
type documentsEnv struct {
srv *Server
dataDir string
dbPath string
auth *auth.JWTService
}
func newDocumentsEnv(t *testing.T, limit int) *documentsEnv {
t.Helper()
dataDir := t.TempDir()
dbPath := filepath.Join(dataDir, "test.db")
conn, err := db.Open(context.Background(), dbPath, nil)
if err != nil {
t.Fatalf("打开临时库失败: %v", err)
}
if err := db.MigrateUp(context.Background(), conn, nil); err != nil {
_ = conn.Close()
t.Fatalf("临时库跑迁移失败: %v", err)
}
if err := conn.Close(); err != nil {
t.Fatalf("关闭临时库失败: %v", err)
}
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
cfg := &config.Config{
Server: config.ServerConfig{ChatDocumentMaxBytes: limit},
Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret},
}
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, DatabasePath: dbPath})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
t.Cleanup(srv.Close)
return &documentsEnv{srv: srv, dataDir: dataDir, dbPath: dbPath, auth: jwt}
}
func (e *documentsEnv) token(t *testing.T, role auth.Role) string {
t.Helper()
tok, err := e.auth.Sign("acct", role, time.Hour)
if err != nil {
t.Fatalf("签发 token 失败: %v", err)
}
return tok
}
func (e *documentsEnv) documentsDir() string {
return filepath.Join(e.dataDir, "uploads", documentSubDir)
}
// documentEnvelope 是两个信封的合并视图:成功 `{data:{…}}`、失败
// `{error:{code,message}}`。
type documentEnvelope struct {
Data struct {
Name string `json:"name"`
URL string `json:"url"`
MIME string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Deduped bool `json:"deduped"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func decodeDocumentEnvelope(t *testing.T, body []byte) documentEnvelope {
t.Helper()
var env documentEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("解析响应失败: %v (body=%s)", err, body)
}
return env
}
// postDocument 组装一次 multipart 上传并直接喂给网关 handler。partContentType
// 是**客户端谎报**的部件类型 —— 它必须无法影响任何裁决(真值是字节本身)。
func postDocument(t *testing.T, env *documentsEnv, filename string, content []byte, partContentType, token string) (*http.Response, []byte) {
t.Helper()
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
header := textproto.MIMEHeader{}
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, documentFormField, filename))
header.Set("Content-Type", partContentType)
part, err := mw.CreatePart(header)
if err != nil {
t.Fatalf("创建 multipart 部件失败: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("写入 multipart 部件失败: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart 失败: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/documents", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
env.srv.Handler().ServeHTTP(rec, req)
return rec.Result(), rec.Body.Bytes()
}
// getDocument 直接以原始 target 调网关 handler(不经过真实网络与客户端重定向
// 策略),因此 `..` 形态到底返回 404 还是 301 能被精确看见。
func getDocument(t *testing.T, env *documentsEnv, target, token string) (*http.Response, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, target, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
env.srv.Handler().ServeHTTP(rec, req)
return rec.Result(), rec.Body.Bytes()
}
// docHashNameOf 复算一份内容在给定文件名下的期望落盘名(内容寻址口径的独立实现)。
func docHashNameOf(t *testing.T, filename string, content []byte) (string, string) {
t.Helper()
sum := sha256.Sum256(content)
hexSum := hex.EncodeToString(sum[:])
return hexSum[:documentHashHexLen] + documentExtOf(filename), hexSum
}
// TestDocumentUploadAcceptsTextFormats 覆盖验收第 1 条:txt/md/csv/json 上传得
// 201,响应含 name/url/mime/size/sha256/deduped(false),且盘上确实落了一份同名文件。
func TestDocumentUploadAcceptsTextFormats(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
tok := env.token(t, auth.RoleOwner)
cases := []struct {
filename string
content []byte
}{
{"notes.txt", []byte("昔涟的测试文本\n")},
{"readme.md", []byte("# 标题\n\n正文\n")},
{"table.csv", []byte("name,age\n昔涟,1\n")},
{"data.json", []byte(`{"who":"cyrene"}`)},
}
for _, tc := range cases {
resp, body := postDocument(t, env, tc.filename, tc.content, "text/plain", tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("%s: status = %d, want 201 (body=%s)", tc.filename, resp.StatusCode, body)
}
got := decodeDocumentEnvelope(t, body)
wantName, wantSHA := docHashNameOf(t, tc.filename, tc.content)
if got.Data.Name != wantName {
t.Fatalf("%s: name = %q, want 内容寻址名 %q", tc.filename, got.Data.Name, wantName)
}
if got.Data.URL != documentFilePrefix+wantName || got.Data.SHA256 != wantSHA {
t.Fatalf("%s: url/sha256 = (%q,%q), want (%q,%q)",
tc.filename, got.Data.URL, got.Data.SHA256, documentFilePrefix+wantName, wantSHA)
}
if got.Data.Size != int64(len(tc.content)) || got.Data.MIME != documentSniffOKMIME || got.Data.Deduped {
t.Fatalf("%s: size/mime/deduped = (%d,%q,%v), want (%d,%q,false)",
tc.filename, got.Data.Size, got.Data.MIME, got.Data.Deduped, len(tc.content), documentSniffOKMIME)
}
stored, err := os.ReadFile(filepath.Join(env.documentsDir(), wantName))
if err != nil {
t.Fatalf("%s: 盘上找不到 %s: %v", tc.filename, wantName, err)
}
if !bytes.Equal(stored, tc.content) {
t.Fatalf("%s: 落盘内容与上传内容不一致(len %d vs %d)", tc.filename, len(stored), len(tc.content))
}
}
}
// TestDocumentUploadDeduplicatesIdenticalBytes 覆盖验收第 2 条:同字节重复上传回
// 200 + 同一个 name + deduped(true),盘上只有一份;且名字只由内容与扩展名决定,
// 客户端换个文件名也命中同一份。
func TestDocumentUploadDeduplicatesIdenticalBytes(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
tok := env.token(t, auth.RoleOwner)
content := []byte("同一份内容的两次上传\n")
resp, body := postDocument(t, env, "first.txt", content, "text/plain", tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("首次上传 status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
first := decodeDocumentEnvelope(t, body)
if first.Data.Deduped {
t.Fatalf("首次上传 deduped = true, want false")
}
resp, body = postDocument(t, env, "first.txt", content, "text/plain", tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("重复上传 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
second := decodeDocumentEnvelope(t, body)
if !second.Data.Deduped {
t.Fatalf("重复上传 deduped = false, want true")
}
if second.Data.Name != first.Data.Name {
t.Fatalf("重复上传 name = %q, want 与首次一致 %q", second.Data.Name, first.Data.Name)
}
// 换一个客户端文件名(扩展名不变)仍是同一份内容寻址名。
resp, body = postDocument(t, env, "renamed.txt", content, "text/plain", tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("改名重传 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if third := decodeDocumentEnvelope(t, body); third.Data.Name != first.Data.Name {
t.Fatalf("改名重传 name = %q, want %q(名字只由内容与扩展名决定)", third.Data.Name, first.Data.Name)
}
entries, err := os.ReadDir(env.documentsDir())
if err != nil {
t.Fatalf("读取文档目录失败: %v", err)
}
if len(entries) != 1 {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("盘上文件数 = %d (%v), want 1(同字节同扩展名只留一份)", len(entries), names)
}
}
// TestDocumentUploadSniffVetoesDeclaredType 覆盖验收第 3 条:客户端把部件
// Content-Type 谎报成 text/plain、文件名也用白名单扩展名,内容却是真 PNG/HTML/zip
// 时,字节嗅探必须否决声明并回 415。
func TestDocumentUploadSniffVetoesDeclaredType(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
tok := env.token(t, auth.RoleOwner)
png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 64)...)
html := []byte("<!DOCTYPE html><html><body>hi</body></html>")
zip := append([]byte("PK\x03\x04"), bytes.Repeat([]byte{0x00}, 64)...)
cases := []struct {
label string
content []byte
}{
{"PNG", png},
{"HTML", html},
{"zip", zip},
}
for _, tc := range cases {
resp, body := postDocument(t, env, "lie.txt", tc.content, "text/plain", tok)
if resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("%s 谎报 text/plain: status = %d, want 415 (body=%s)", tc.label, resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentUnsupportedCode {
t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentUnsupportedCode)
}
}
if _, err := os.Stat(env.documentsDir()); !os.IsNotExist(err) {
t.Fatalf("被嗅探否决的上传仍创建了文档目录(err=%v)", err)
}
}
// TestDocumentUploadRejectsDisallowedTypes 覆盖验收第 4 条:HTML/SVG/XML/zip/
// docx/exe,以及嗅探成纯文本但扩展名不在白名单的 .js/.py/.sh,一律 415。
func TestDocumentUploadRejectsDisallowedTypes(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
tok := env.token(t, auth.RoleOwner)
zipLike := append([]byte("PK\x03\x04"), bytes.Repeat([]byte{0x00}, 64)...)
pngLike := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 64)...)
cases := []struct {
label string
filename string
content []byte
}{
{"html", "evil.html", []byte("<html><body>x</body></html>")},
{"svg", "logo.svg", []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`)},
{"xml", "data.xml", []byte(`<?xml version="1.0"?><a/>`)},
{"zip", "pack.zip", zipLike},
{"docx", "report.docx", zipLike},
{"exe", "tool.exe", pngLike},
{"js", "app.js", []byte("const a = 1;\n")},
{"py", "tool.py", []byte("print('hi')\n")},
{"sh", "run.sh", []byte("#!/bin/sh\necho hi\n")},
}
for _, tc := range cases {
resp, body := postDocument(t, env, tc.filename, tc.content, "text/plain", tok)
if resp.StatusCode != http.StatusUnsupportedMediaType {
t.Fatalf("%s(%s): status = %d, want 415 (body=%s)", tc.label, tc.filename, resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentUnsupportedCode {
t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentUnsupportedCode)
}
}
}
// TestDocumentUploadRejectsOversize 覆盖验收第 5 条:文件超过生效上限(含恰好
// 超一个字节的边界)回 413 document_too_large;恰好等于上限必须通过。请求体本身
// 超上限(multipart 信封一起撑爆)走同一条错误码。
func TestDocumentUploadRejectsOversize(t *testing.T) {
t.Parallel()
const limit = 1024
env := newDocumentsEnv(t, limit)
tok := env.token(t, auth.RoleOwner)
resp, body := postDocument(t, env, "big.txt", bytes.Repeat([]byte("a"), limit+1), "text/plain", tok)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("超限一个字节: status = %d, want 413 (body=%s)", resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentTooLargeCode {
t.Fatalf("超限一个字节: error.code = %q, want %q", code, documentTooLargeCode)
}
huge := bytes.Repeat([]byte("a"), 2*limit+2*documentMultipartSlack)
resp, body = postDocument(t, env, "huge.txt", huge, "text/plain", tok)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("请求体超限: status = %d, want 413 (body=%s)", resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentTooLargeCode {
t.Fatalf("请求体超限: error.code = %q, want %q", code, documentTooLargeCode)
}
resp, body = postDocument(t, env, "exact.txt", bytes.Repeat([]byte("b"), limit), "text/plain", tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("恰好等于上限: status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
}
// TestDocumentReadRequiresOwnerOrAdmin 覆盖验收第 6 条:无 Bearer / 无效 Bearer /
// viewer 角色读 → 401/403;owner 与 admin 读 → 200 且带 attachment + nosniff,
// 字节与上传内容逐字一致。
func TestDocumentReadRequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
content := []byte("读侧鉴权与不可渲染断言\n")
resp, body := postDocument(t, env, "auth.txt", content, "text/plain", env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusCreated {
t.Fatalf("准备上传失败: status = %d (body=%s)", resp.StatusCode, body)
}
name := decodeDocumentEnvelope(t, body).Data.Name
target := documentFilePrefix + name
cases := []struct {
label string
token string
want int
}{
{"无 Bearer", "", http.StatusUnauthorized},
{"无效 Bearer", "not-a-jwt", http.StatusUnauthorized},
{"viewer 角色", env.token(t, auth.RoleViewer), http.StatusForbidden},
}
for _, tc := range cases {
resp, body := getDocument(t, env, target, tc.token)
if resp.StatusCode != tc.want {
t.Fatalf("%s: status = %d, want %d (body=%s)", tc.label, resp.StatusCode, tc.want, body)
}
}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
resp, body := getDocument(t, env, target, env.token(t, role))
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s 读: status = %d, want 200 (body=%s)", role, resp.StatusCode, body)
}
disposition := resp.Header.Get("Content-Disposition")
if !strings.HasPrefix(disposition, "attachment") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("%s 读: 响应头 = (%q,%q), want attachment 前缀 + nosniff(永不可渲染)",
role, disposition, resp.Header.Get("X-Content-Type-Options"))
}
if !bytes.Equal(body, content) {
t.Fatalf("%s 读: 响应字节与上传内容不一致(len %d vs %d)", role, len(body), len(content))
}
}
}
// TestDocumentReadRejectsTraversalAndMalformedNames 覆盖验收第 7 条:字面 `..`、
// 转义后的 `..`/斜杠、非 32 位十六进制主名、非白名单扩展名一律 404
// document_not_found(且**不是** ServeMux 的 301 重定向)。
func TestDocumentReadRejectsTraversalAndMalformedNames(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
tok := env.token(t, auth.RoleOwner)
validName, _ := docHashNameOf(t, "x.txt", []byte("x"))
stem := strings.TrimSuffix(validName, ".txt")
cases := []struct {
label string
target string
}{
{"字面父目录段", "/api/v1/documents/../../etc/passwd"},
{"转义的父目录段", "/api/v1/documents/%2e%2e%2f%2e%2e%2fetc%2fpasswd"},
{"转义斜杠", "/api/v1/documents/..%2f"},
{"缺扩展名", documentFilePrefix + stem},
{"非白名单扩展名", documentFilePrefix + stem + ".exe"},
{"大写十六进制", documentFilePrefix + strings.ToUpper(stem) + ".txt"},
{"主名长度不对", documentFilePrefix + "0123456789abcdef.txt"},
{"名字不存在", documentFilePrefix + strings.Repeat("ab", documentHashHexLen) + ".txt"},
}
for _, tc := range cases {
resp, body := getDocument(t, env, tc.target, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("%s(%s): status = %d, want 404 (body=%s)", tc.label, tc.target, resp.StatusCode, body)
}
if code := decodeDocumentEnvelope(t, body).Error.Code; code != documentNotFoundCode {
t.Fatalf("%s: error.code = %q, want %q", tc.label, code, documentNotFoundCode)
}
}
}
// TestDocumentUploadWritesMetadataRow 覆盖验收第 8 条:上传成功后 documents 表里
// 确实有一行(name/sha256/mime/size/uploader),且 S7 要用的引用计数列存在。
func TestDocumentUploadWritesMetadataRow(t *testing.T) {
t.Parallel()
env := newDocumentsEnv(t, 0)
content := []byte("元数据行断言\n")
resp, body := postDocument(t, env, "meta.txt", content, "text/plain", env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusCreated {
t.Fatalf("准备上传失败: status = %d (body=%s)", resp.StatusCode, body)
}
got := decodeDocumentEnvelope(t, body)
conn, err := db.Open(context.Background(), env.dbPath, nil)
if err != nil {
t.Fatalf("打开临时库失败: %v", err)
}
defer func() { _ = conn.Close() }()
var name, sha, mimeType, uploader string
var size, refCount int64
var referenced *string
row := conn.QueryRowContext(context.Background(),
`SELECT name, sha256, mime, size, uploader, ref_count, last_referenced_at FROM documents WHERE name = ?`,
got.Data.Name)
if err := row.Scan(&name, &sha, &mimeType, &size, &uploader, &refCount, &referenced); err != nil {
t.Fatalf("documents 表里没有对应元数据行: %v", err)
}
if name != got.Data.Name || sha != got.Data.SHA256 || mimeType != got.Data.MIME {
t.Fatalf("元数据行 = (%s,%s,%s), want (%s,%s,%s)", name, sha, mimeType, got.Data.Name, got.Data.SHA256, got.Data.MIME)
}
if size != int64(len(content)) || uploader != "acct" {
t.Fatalf("元数据 size/uploader = (%d,%q), want (%d,%q)", size, uploader, len(content), "acct")
}
if refCount != 0 || referenced != nil {
t.Fatalf("引用计数列 = (%d,%v), want (0,nil)(S1 只写元数据,引用计数属 S7)", refCount, referenced)
}
}
// TestDocumentLimitConfigContract 钉住限额口径:默认值与 env 标签逐字节一致、
// ≤0 回落到默认、**不做钳位**(文档上限与图片上限刻意解耦,不得互相牵制)。
func TestDocumentLimitConfigContract(t *testing.T) {
t.Parallel()
field, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName("ChatDocumentMaxBytes")
if !ok {
t.Fatal("config.ServerConfig 缺 ChatDocumentMaxBytes 字段(配置契约已断)")
}
if got := field.Tag.Get("env"); got != "CHAT_DOCUMENT_MAX_BYTES" {
t.Fatalf("env 标签 = %q, want CHAT_DOCUMENT_MAX_BYTES", got)
}
if got := field.Tag.Get("default"); got != strconv.Itoa(maxDocumentBytes) {
t.Fatalf("默认值 = %q, want %d", got, maxDocumentBytes)
}
if maxDocumentBytes == maxUploadBytes {
t.Fatalf("文档上限 = 图片上限 = %d:两者必须解耦(design/24 §3.3)", maxDocumentBytes)
}
for _, configured := range []int{0, -1, -1 << 20} {
if got := documentLimitBytes(configured); got != maxDocumentBytes {
t.Fatalf("documentLimitBytes(%d) = %d, want 默认 %d", configured, got, maxDocumentBytes)
}
}
wider := maxDocumentBytes + 1
if got := documentLimitBytes(wider); got != wider {
t.Fatalf("documentLimitBytes(%d) = %d, want 原值(文档上限无解码层硬顶,不做钳位)", wider, got)
}
}