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("hi") 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("x")}, {"svg", "logo.svg", []byte(``)}, {"xml", "data.xml", []byte(``)}, {"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) } }