Files
Cyrene_v2/internal/services/gateway/upload_test.go
T
AskaEth 7ecb43bd5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 图片上传端点(B1) — POST /v1/upload + GET /v1/files/{name}
- upload.go: multipart上传(owner/admin+审计)→ {url,size,mime,name}; MaxBytesReader 5MB + DetectContentType嗅探 + 白名单raster(拒SVG/HTML) + crypto/rand 128-bit随机名 + 0700目录
- 静态服务: 同端口 GET /v1/files/{name}, 能力URL, 路径穿越全形态拒绝(.. / %2f / %2e%2e), nosniff, Range
- errors: ErrPayloadTooLarge(413)/ErrUnsupportedMediaType(415)
- server.go 路由+Deps.DataDir; wire.go resolveDataDir 注入
- 8测试+真实二进制E2E, build/vet/test绿+交叉编译
- Docs/api-client v0.24 + api-reference §0.3 同步
2026-09-08 21:36:03 +08:00

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 is the smallest byte string the stdlib sniffer recognizes as
// image/png (the 8-byte signature plus padding); the handler only sniffs the
// head, it never decodes the image.
var testPNG = append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
// uploadTestEnv builds a Server whose data dir is a fresh temp dir and returns
// the httptest server, the JWT service and the resolved data dir.
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 sends a multipart/form-data POST to /api/v1/upload. field is the
// part name (normally "file"); filename is the client-supplied name, which the
// handler must ignore.
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 is the decoded upload response.
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)
}
// The client's original filename must never reach disk or the response.
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)
}
// The static route is a capability URL: no bearer token, and the bytes and
// caching/type headers must come back intact.
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()
// A non-nil frontend enables the SPA wrapper in Handler; the /api/v1/files/
// route must still reach the API mux instead of falling back to 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()
// A non-nil frontend reproduces production routing: without the explicit
// guard, ServeMux would clean the raw `..` path and redirect to a path the SPA
// then answers with index.html.
frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>spa</html>")}}
_, ts, _ := buildEnv(t, cfg, Deps{Frontend: frontend})
// A file that really exists directly under the data dir (not in uploads/)
// must not be reachable through the static route.
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)
}
}
}