- 字节嗅探唯一裁决 + 扩展名白名单 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 重定向,正是那道防护存在的理由)。
498 lines
20 KiB
Go
498 lines
20 KiB
Go
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)
|
||
}
|
||
}
|