主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
320 lines
10 KiB
Go
320 lines
10 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
)
|
|
|
|
// testPNG 是标准库嗅探器能识别为 image/png 的最小字节串
|
|
// (8 字节签名加填充);handler 只嗅探头部,
|
|
// 绝不解码该图片。
|
|
var testPNG = append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
|
|
|
|
// uploadTestEnv 构建一个 Server,其数据目录是全新的临时目录,返回
|
|
// httptest 服务器、JWT 服务与解析后的数据目录。
|
|
func uploadTestEnv(t *testing.T) (*httptest.Server, *auth.JWTService, string) {
|
|
t.Helper()
|
|
cfg := cfgDefault()
|
|
cfg.DataDir = t.TempDir()
|
|
_, ts, jwt := buildEnv(t, cfg, Deps{})
|
|
return ts, jwt, cfg.DataDir
|
|
}
|
|
|
|
// postUpload 向 /api/v1/upload 发送 multipart/form-data POST。field 是
|
|
// 部件名(通常为 "file");filename 是客户端提供的名字,
|
|
// handler 必须忽略它。
|
|
func postUpload(t *testing.T, ts *httptest.Server, token, field, filename string, content []byte) (*http.Response, []byte) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, err := mw.CreateFormFile(field, filename)
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(content); err != nil {
|
|
t.Fatalf("write part: %v", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatalf("close writer: %v", err)
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/upload", &buf)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read response: %v", err)
|
|
}
|
|
return resp, body
|
|
}
|
|
|
|
// uploadData 是解码后的上传响应。
|
|
type uploadData struct {
|
|
URL string `json:"url"`
|
|
Size int64 `json:"size"`
|
|
MIME string `json:"mime"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func decodeUploadData(t *testing.T, body []byte) uploadData {
|
|
t.Helper()
|
|
var out uploadData
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
|
t.Fatalf("decode upload data: %v (body=%s)", err, body)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestUploadAndServeImage(t *testing.T) {
|
|
t.Parallel()
|
|
ts, jwt, dataDir := uploadTestEnv(t)
|
|
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("sign token: %v", err)
|
|
}
|
|
|
|
resp, body := postUpload(t, ts, tok, "file", "../../evil.png", testPNG)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
data := decodeUploadData(t, body)
|
|
if data.MIME != "image/png" {
|
|
t.Fatalf("mime = %q, want image/png", data.MIME)
|
|
}
|
|
if data.Size != int64(len(testPNG)) {
|
|
t.Fatalf("size = %d, want %d", data.Size, len(testPNG))
|
|
}
|
|
if !strings.HasPrefix(data.URL, "http://") || !strings.Contains(data.URL, uploadFilePrefix) {
|
|
t.Fatalf("url = %q, want absolute %s URL", data.URL, uploadFilePrefix)
|
|
}
|
|
// 客户端原始文件名绝不得到达磁盘或响应。
|
|
if strings.Contains(data.Name, "evil") || strings.ContainsAny(data.Name, "/\\") {
|
|
t.Fatalf("name = %q, want a server-generated hex name", data.Name)
|
|
}
|
|
if !validUploadName(data.Name) {
|
|
t.Fatalf("name = %q, not a valid storage name", data.Name)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dataDir, "uploads", data.Name)); err != nil {
|
|
t.Fatalf("stored file: %v", err)
|
|
}
|
|
|
|
// 静态路由是能力 URL:不带 bearer token,且字节与
|
|
// 缓存/类型头必须原样返回。
|
|
get, err := ts.Client().Get(data.URL)
|
|
if err != nil {
|
|
t.Fatalf("get upload: %v", err)
|
|
}
|
|
defer func() { _ = get.Body.Close() }()
|
|
if get.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status = %d, want 200", get.StatusCode)
|
|
}
|
|
if ct := get.Header.Get("Content-Type"); ct != "image/png" {
|
|
t.Fatalf("content-type = %q, want image/png", ct)
|
|
}
|
|
if cl := get.Header.Get("Content-Length"); cl != strconv.Itoa(len(testPNG)) {
|
|
t.Fatalf("content-length = %q, want %d", cl, len(testPNG))
|
|
}
|
|
if get.Header.Get("Cache-Control") == "" {
|
|
t.Fatal("cache-control is empty")
|
|
}
|
|
got, err := io.ReadAll(get.Body)
|
|
if err != nil {
|
|
t.Fatalf("read served body: %v", err)
|
|
}
|
|
if !bytes.Equal(got, testPNG) {
|
|
t.Fatalf("served body = %q, want the uploaded bytes", got)
|
|
}
|
|
}
|
|
|
|
func TestUploadRejectsNonImage(t *testing.T) {
|
|
t.Parallel()
|
|
ts, jwt, _ := uploadTestEnv(t)
|
|
tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
|
|
resp, body := postUpload(t, ts, tok, "file", "notes.txt", []byte("just some plain text"))
|
|
if resp.StatusCode != http.StatusUnsupportedMediaType {
|
|
t.Fatalf("status = %d, want 415 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
wantErrorShape(t, body)
|
|
}
|
|
|
|
func TestUploadRejectsOversize(t *testing.T) {
|
|
t.Parallel()
|
|
ts, jwt, _ := uploadTestEnv(t)
|
|
tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
|
|
big := make([]byte, maxUploadBytes+1)
|
|
copy(big, testPNG)
|
|
resp, body := postUpload(t, ts, tok, "file", "big.png", big)
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want 413 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
wantErrorShape(t, body)
|
|
}
|
|
|
|
func TestUploadMissingFile(t *testing.T) {
|
|
t.Parallel()
|
|
ts, jwt, _ := uploadTestEnv(t)
|
|
tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
|
|
resp, body := postUpload(t, ts, tok, "wrongfield", "x.png", testPNG)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
wantErrorShape(t, body)
|
|
}
|
|
|
|
func TestUploadRequiresAuth(t *testing.T) {
|
|
t.Parallel()
|
|
ts, jwt, _ := uploadTestEnv(t)
|
|
viewer, _ := jwt.Sign("acct", auth.RoleViewer, time.Hour)
|
|
|
|
if resp, body := postUpload(t, ts, "", "file", "x.png", testPNG); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("no token status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if resp, body := postUpload(t, ts, viewer, "file", "x.png", testPNG); resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
func TestUploadAudits(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := cfgDefault()
|
|
cfg.DataDir = t.TempDir()
|
|
store := newMemoryAuditStore()
|
|
_, ts, jwt := buildEnv(t, cfg, Deps{AuditStore: store})
|
|
tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
|
|
if resp, body := postUpload(t, ts, tok, "file", "x.png", testPNG); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
records, err := store.List(context.Background(), 0)
|
|
if err != nil {
|
|
t.Fatalf("list audit: %v", err)
|
|
}
|
|
found := false
|
|
for _, rec := range records {
|
|
if rec.What == "upload.image" && rec.Result == "ok" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("no upload.image audit record in %+v", records)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileServedThroughSPA(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := cfgDefault()
|
|
cfg.DataDir = t.TempDir()
|
|
// 非 nil 的 frontend 会在 Handler 中启用 SPA 包装;
|
|
// /api/v1/files/ 路由仍必须抵达 API mux,而不是回退到 index.html。
|
|
frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>spa</html>")}}
|
|
_, ts, jwt := buildEnv(t, cfg, Deps{Frontend: frontend})
|
|
tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
|
|
|
resp, body := postUpload(t, ts, tok, "file", "x.png", testPNG)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
data := decodeUploadData(t, body)
|
|
get, err := ts.Client().Get(data.URL)
|
|
if err != nil {
|
|
t.Fatalf("get upload: %v", err)
|
|
}
|
|
defer func() { _ = get.Body.Close() }()
|
|
if get.StatusCode != http.StatusOK {
|
|
t.Fatalf("get status = %d, want 200 (SPA must not swallow the route)", get.StatusCode)
|
|
}
|
|
got, _ := io.ReadAll(get.Body)
|
|
if !bytes.Equal(got, testPNG) {
|
|
t.Fatalf("served body = %q, want the uploaded bytes", got)
|
|
}
|
|
}
|
|
|
|
func TestUploadFileRejectsTraversal(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := cfgDefault()
|
|
cfg.DataDir = t.TempDir()
|
|
// 非 nil 的 frontend 复现生产路由:没有显式
|
|
// 防护时,ServeMux 会清理原始 `..` 路径并重定向到一个 SPA
|
|
// 随后用 index.html 应答的路径。
|
|
frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>spa</html>")}}
|
|
_, ts, _ := buildEnv(t, cfg, Deps{Frontend: frontend})
|
|
// 直接存在于数据目录下(不在 uploads/ 中)的真实文件
|
|
// 绝不得经静态路由可达。
|
|
if err := os.WriteFile(filepath.Join(cfg.DataDir, "secret.png"), testPNG, 0o600); err != nil {
|
|
t.Fatalf("write decoy: %v", err)
|
|
}
|
|
paths := []string{
|
|
"/api/v1/files/",
|
|
"/api/v1/files/..%2f..%2f..%2fetc%2fpasswd",
|
|
"/api/v1/files/../../../../etc/passwd",
|
|
"/api/v1/files/%2e%2e%2fsecret.png",
|
|
"/api/v1/files/secret.png",
|
|
"/api/v1/files/00000000000000000000000000000000.png",
|
|
}
|
|
for _, p := range paths {
|
|
resp, err := ts.Client().Get(ts.URL + p)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", p, err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("get %s status = %d, want 404", p, resp.StatusCode)
|
|
}
|
|
if bytes.Contains(body, []byte("root:")) {
|
|
t.Fatalf("get %s leaked /etc/passwd", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidUploadName(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
want bool
|
|
}{
|
|
{name: "0123456789abcdef0123456789abcdef.png", want: true},
|
|
{name: "0123456789abcdef0123456789abcdef.webp", want: true},
|
|
{name: "", want: false},
|
|
{name: ".", want: false},
|
|
{name: "..", want: false},
|
|
{name: "abcd.png", want: false},
|
|
{name: "0123456789abcdef0123456789abcdef.exe", want: false},
|
|
{name: "0123456789abcdef0123456789abcdeZ.png", want: false},
|
|
{name: "0123456789abcdef0123456789abcdef", want: false},
|
|
{name: "../0123456789abcdef0123456789abcdef.png", want: false},
|
|
{name: "0123456789abcdef0123456789abcdef.png/../x.png", want: false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := validUploadName(tc.name); got != tc.want {
|
|
t.Errorf("validUploadName(%q) = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|