// 功能描述:报修上传的内容幂等、格式、跨账户和路径边界回归。 // 版本:1.0.0。 package upload import ( "bytes" "encoding/json" "github.com/gin-gonic/gin" "mime/multipart" "net/http/httptest" "path" "strings" "testing" ) func uploadPhotoForTest(t *testing.T, owner, filename string, content []byte) (string, int) { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", filename) if err != nil { t.Fatal(err) } part.Write(content) writer.Close() response := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(response) ctx.Request = httptest.NewRequest("POST", "/ticket-photos", &body) ctx.Request.Header.Set("Content-Type", writer.FormDataContentType()) UploadTicketPhoto(ctx, owner) var result struct { Code int `json:"code"` Details json.RawMessage `json:"details"` } if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { t.Fatal(err) } var details struct { URI string `json:"uri"` } if result.Code == 0 { if err := json.Unmarshal(result.Details, &details); err != nil { t.Fatal(err) } } return details.URI, result.Code } // TestDraftPhotoRead 验证草稿读取只能访问当前账户已上传的文件名。 func TestDraftPhotoRead(t *testing.T) { t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) picture := pngBytes(t, 2, 2) uri, code := uploadPhotoForTest(t, "alice", "image.png", picture) if code != 0 { t.Fatal("上传失败", code) } for _, scenario := range []struct { owner, name string status int }{ {"alice", path.Base(uri), 200}, {"bob", path.Base(uri), 404}, {"alice", "../" + path.Base(uri), 404}, {"alice", "unknown.png", 404}, } { response := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(response) ctx.Request = httptest.NewRequest("GET", "/ticket-photos/file", nil) ServeOwnedTicketPhoto(ctx, scenario.owner, scenario.name) ctx.Writer.WriteHeaderNow() if response.Code != scenario.status { t.Fatalf("%s: 状态 %d", scenario.owner, response.Code) } if scenario.status == 200 && !bytes.Equal(response.Body.Bytes(), picture) { t.Fatal("图片内容不一致") } } } func TestTicketPhotoStorage(t *testing.T) { t.Setenv("HEQI_UPLOAD_DIR", t.TempDir()) picture := pngBytes(t, 2, 2) first, code := uploadPhotoForTest(t, "alice", "image.png", picture) if code != 0 || !OwnsTicketPhoto("alice", first) { t.Fatal("上传失败", code) } second, code := uploadPhotoForTest(t, "alice", "other.png", picture) if code != 0 || first != second { t.Fatal("重复上传未复用") } if OwnsTicketPhoto("bob", first) { t.Fatal("跨账户引用") } for _, uri := range []string{first + "/../secret.png", strings.ReplaceAll(first, "/", "\\"), "https://example.com/a.png", "/uploads/avatars/a.png"} { if OwnsTicketPhoto("alice", uri) { t.Fatal("非法路径", uri) } } for _, data := range [][]byte{[]byte("fake"), pngBytes(t, 4097, 1), make([]byte, (2<<20)+1)} { if _, code := uploadPhotoForTest(t, "alice", "x.png", data); code == 0 { t.Fatal("错误图片被接受") } } response := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(response) ctx.Request = httptest.NewRequest("GET", "/photo", nil) ServeTicketPhoto(ctx, "alice", first) if response.Code != 200 || response.Header().Get("Cache-Control") != "private, no-store" || !bytes.Equal(response.Body.Bytes(), picture) { t.Fatal("读取或缓存保护失败") } response = httptest.NewRecorder() ctx, _ = gin.CreateTestContext(response) ctx.Request = httptest.NewRequest("GET", "/photo", nil) ServeTicketPhoto(ctx, "bob", first) ctx.Writer.WriteHeaderNow() if response.Code != 404 { t.Fatal("越权读取") } }