Files
gochat/backend/internal/handler/api/v1/upload_handler_test.go
T
Rogeeandrogee 77c91662d2 HH-438: close upload and remote URL attack surfaces (#89)
* HH-438: close upload and remote URL attack surfaces

* HH-438: use valid PNG in direct upload test

* HH-438: align PostgreSQL upload staging schema

* HH-438: run upload migrations before PostgreSQL E2E

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-21 20:36:52 +08:00

539 lines
22 KiB
Go

package v1
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
var handlerTestPNG = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0}
// mockUploadService implements a mock for UploadService handler tests.
// We can't easily mock UploadService because it's a concrete type, not an interface.
// Instead, we test the handler layer by checking HTTP status codes and response shapes.
func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// Account upload route
api := r.Group("/api/v1/accounts/:account_id")
api.POST("/upload", h.Upload)
// Account direct upload route
api.POST("/direct_uploads", h.AccountDirectUpload)
api.POST("/conversations/:conversation_id/direct_uploads", h.ConversationDirectUpload)
api.PUT("/conversations/:conversation_id/direct_uploads/:upload_uuid", h.CompleteConversationDirectUpload)
// Widget direct upload route
widget := r.Group("/widget")
widget.POST("/direct_uploads", h.DirectUpload)
widget.PUT("/direct_uploads/:upload_uuid", h.CompleteWidgetDirectUpload)
chatwootWidget := r.Group("/api/v1/widget")
chatwootWidget.POST("/direct_uploads", h.DirectUpload)
chatwootWidget.PUT("/direct_uploads/:upload_uuid", h.CompleteWidgetDirectUpload)
return r
}
// makeMultipartUploadBody creates a multipart form body with a file field.
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
return makeMultipartUploadBodyWithField("file", filename, content)
}
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
return nil, "", err
}
if _, err := part.Write(content); err != nil {
return nil, "", err
}
if err := writer.Close(); err != nil {
return nil, "", err
}
return body, writer.FormDataContentType(), nil
}
func TestUploadHandler_Upload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
// Send request without file (no multipart body)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
// Set account_id in context (simulating middleware)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", handlerTestPNG)
require.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
assert.Equal(t, payload["blob_id"], payload["blob_key"])
}
func TestUploadHandler_Upload_RejectsLoopbackExternalURL(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("external image"))
}))
defer server.Close()
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.Contains(t, payload["error"], "SSRF")
}
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
req, _ := http.NewRequest("POST", "/widget/direct_uploads", nil)
req.Header.Set("Content-Type", "multipart/form-data")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUploadHandler_DirectUploadRejectsOversizedBody(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
svc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{MaxFileSize: 1}})
router := setupUploadHandlerRouter(NewUploadHandler(svc))
body, contentType, err := makeMultipartUploadBody("large.png", bytes.Repeat([]byte{'x'}, 2<<20))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/widget/direct_uploads?website_token=test", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
}
func TestUploadHandler_WidgetActiveStorageDirectUploadFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Widget Upload Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
channelConfig, err := json.Marshal(service.WebWidgetConfig{WebsiteToken: "upload_ws_token"})
require.NoError(t, err)
inbox := &model.Inbox{AccountID: account.ID, Name: "Upload Widget", ChannelType: "web_widget", Enabled: true, ChannelConfig: string(channelConfig)}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Uploader"}
require.NoError(t, db.Create(contact).Error)
contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, PubsubToken: "upload_pubsub_token"}
require.NoError(t, db.Create(contactInbox).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{
Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20},
}).WithWidgetAuth(repository.NewInboxRepo(db), repository.NewContactInboxRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
uploadContent := handlerTestPNG
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "visitor.png",
"byte_size": len(uploadContent),
"checksum": "checksum-token",
"content_type": "image/png",
"metadata": map[string]any{"identified": true},
},
})
require.NoError(t, err)
wCreate := httptest.NewRecorder()
reqCreate, _ := http.NewRequest("POST", "/api/v1/widget/direct_uploads?website_token=upload_ws_token", bytes.NewReader(metadataBody))
reqCreate.Header.Set("Content-Type", "application/json")
reqCreate.Header.Set("X-Auth-Token", "upload_pubsub_token")
router.ServeHTTP(wCreate, reqCreate)
require.Equal(t, http.StatusOK, wCreate.Code)
var createResp map[string]any
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
signedID, ok := createResp["signed_id"].(string)
require.True(t, ok)
require.NotEmpty(t, signedID)
assert.Equal(t, "visitor.png", createResp["filename"])
directUpload := createResp["direct_upload"].(map[string]any)
assert.Contains(t, directUpload["url"], "/api/v1/widget/direct_uploads/"+signedID+"?token=")
assert.Equal(t, map[string]any{"Content-Type": "image/png"}, directUpload["headers"])
assert.Equal(t, "gochat_local", createResp["service_name"])
assert.Equal(t, float64(len(uploadContent)), createResp["byte_size"])
assert.Equal(t, "checksum-token", createResp["checksum"])
assert.Equal(t, true, createResp["metadata"].(map[string]any)["identified"])
assert.NotEmpty(t, createResp["key"])
wPut := httptest.NewRecorder()
reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader(uploadContent))
reqPut.Header.Set("Content-Type", "image/png")
router.ServeHTTP(wPut, reqPut)
require.Equal(t, http.StatusOK, wPut.Code)
var completeResp map[string]any
require.NoError(t, json.Unmarshal(wPut.Body.Bytes(), &completeResp))
require.Equal(t, true, completeResp["success"])
completeData := completeResp["data"].(map[string]any)
assert.Equal(t, signedID, completeData["upload_uuid"])
assert.Equal(t, "visitor.png", completeData["original_name"])
assert.Equal(t, "image", completeData["file_type"])
assert.Equal(t, "image/png", completeData["mime_type"])
assert.Equal(t, float64(len(uploadContent)), completeData["file_size"])
var upload model.DirectUpload
require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error)
assert.Equal(t, account.ID, upload.AccountID)
storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "widget_direct", signedID+".png"))
require.NoError(t, err)
assert.Equal(t, uploadContent, storedBytes)
}
func TestUploadHandler_ConversationActiveStorageDirectUploadFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Conversation Upload Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Inbox", ChannelType: "web_widget", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Composer"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(44)
conversation := &model.Conversation{
AccountID: account.ID,
InboxID: inbox.ID,
ContactID: contact.ID,
DisplayID: &displayID,
Status: "open",
ChannelType: "web_widget",
Channel: "web_widget",
}
require.NoError(t, db.Create(conversation).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{
Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20},
}).WithConversationRepo(repository.NewConversationRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
uploadContent := []byte("%PDF-1.4\nhello report")
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "agent-note.pdf",
"byte_size": len(uploadContent),
"checksum": "pdf-checksum",
"content_type": "application/pdf",
"metadata": map[string]any{"identified": true},
},
})
require.NoError(t, err)
createPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/44/direct_uploads"
wCreate := httptest.NewRecorder()
reqCreate, _ := http.NewRequest("POST", createPath, bytes.NewReader(metadataBody))
reqCreate.Header.Set("Content-Type", "application/json")
router.ServeHTTP(wCreate, reqCreate)
require.Equal(t, http.StatusOK, wCreate.Code)
var createResp map[string]any
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
signedID, ok := createResp["signed_id"].(string)
require.True(t, ok)
require.NotEmpty(t, signedID)
assert.Equal(t, "agent-note.pdf", createResp["filename"])
directUpload := createResp["direct_upload"].(map[string]any)
assert.Contains(t, directUpload["url"], createPath+"/"+signedID+"?token=")
assert.Equal(t, map[string]any{"Content-Type": "application/pdf"}, directUpload["headers"])
assert.Equal(t, "gochat_local", createResp["service_name"])
assert.Equal(t, float64(len(uploadContent)), createResp["byte_size"])
assert.Equal(t, "pdf-checksum", createResp["checksum"])
assert.NotEmpty(t, createResp["key"])
wPut := httptest.NewRecorder()
reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader(uploadContent))
reqPut.Header.Set("Content-Type", "application/pdf")
router.ServeHTTP(wPut, reqPut)
require.Equal(t, http.StatusOK, wPut.Code)
var completeResp map[string]any
require.NoError(t, json.Unmarshal(wPut.Body.Bytes(), &completeResp))
require.Equal(t, true, completeResp["success"])
completeData := completeResp["data"].(map[string]any)
assert.Equal(t, signedID, completeData["upload_uuid"])
assert.Equal(t, "agent-note.pdf", completeData["original_name"])
assert.Equal(t, "file", completeData["file_type"])
assert.Equal(t, "application/pdf", completeData["mime_type"])
assert.Equal(t, float64(len(uploadContent)), completeData["file_size"])
var upload model.DirectUpload
require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error)
assert.Equal(t, account.ID, upload.AccountID)
assert.Equal(t, model.DirectUploadSourceAccount, upload.Source)
storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "account", strconv.FormatUint(uint64(account.ID), 10), signedID+".pdf"))
require.NoError(t, err)
assert.Equal(t, uploadContent, storedBytes)
}
func TestUploadHandler_ConversationActiveStorageDirectUploadRejectsUnsupportedMIME(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Conversation Upload Validation Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Validation Inbox", ChannelType: "web_widget", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Composer Validation"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(45)
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
require.NoError(t, db.Create(conversation).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: t.TempDir(), MaxFileSize: 50 << 20}}).WithConversationRepo(repository.NewConversationRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "malware.exe",
"byte_size": 12,
"checksum": "bad-checksum",
"content_type": "application/x-msdownload",
},
})
require.NoError(t, err)
w := httptest.NewRecorder()
path := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/45/direct_uploads"
req, _ := http.NewRequest("POST", path, bytes.NewReader(metadataBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.Equal(t, false, payload["success"])
assert.Equal(t, "VALIDATION_ERROR", payload["error"].(map[string]any)["code"])
assert.Contains(t, payload["error"].(map[string]any)["message"], "unsupported file type")
}
func TestUploadHandler_AccountDirectUpload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
// Send request without file (no multipart body)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/direct_uploads", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUploadHandler_AccountDirectUpload_NoAccountID(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
// Use a non-numeric account_id so getAccountID returns 0
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/direct_uploads", nil)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestUploadHandler_AccountDirectUpload_WithFileButNilService(t *testing.T) {
// Create handler with nil service — request passes validation but service call panics.
// This test verifies that account_id extraction + file extraction work correctly
// before the service call. In real integration tests, we use a real service.
h := &UploadHandler{svc: nil}
r := setupUploadHandlerRouter(h)
body, contentType, err := makeMultipartUploadBody("test.png", handlerTestPNG)
assert.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/direct_uploads", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
// This will panic because svc is nil, but it proves validation passes.
// We recover the panic and verify the request got past the validation checks.
defer func() {
if r := recover(); r != nil {
// Expected: nil service causes panic after validation passes
t.Logf("Recovered expected panic from nil service: %v", r)
}
}()
r.ServeHTTP(w, req)
}
func TestUploadHandler_PrivateUploadReturns404AcrossTenants(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
fileURL := "/uploads/account/1/private.png"
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "account", "1"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "account", "1", "private.png"), handlerTestPNG, 0o600))
require.NoError(t, db.Create(&model.DirectUpload{
UploadUUID: "private-upload", AccountID: 1, Status: model.DirectUploadStatusCompleted,
Source: model.DirectUploadSourceAccount, OriginalName: "private.png", FileType: "image",
MimeType: "image/png", FileSize: int64(len(handlerTestPNG)), FileURL: fileURL, ExpiresAt: time.Now().Add(time.Hour),
}).Error)
jwtCfg := &config.JWTConfig{Secret: "upload-access-test-secret", ExpiryHours: 1, RefreshExpiryHours: 1}
jwtSvc := auth.NewJWTService(jwtCfg)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir}}).WithAccessDB(db)
handler := NewUploadHandler(uploadSvc).WithAccessAuth(jwtSvc)
router := gin.New()
router.GET("/uploads/*filepath", handler.ServeUpload)
tokenFor := func(accountID uint) string {
pair, tokenErr := jwtSvc.GenerateTokenPair(&model.User{Base: model.Base{ID: 1}, Provider: "email"}, accountID, "agent")
require.NoError(t, tokenErr)
return pair.AccessToken
}
for _, test := range []struct {
name string
token string
want int
}{
{name: "anonymous", want: http.StatusNotFound},
{name: "other tenant", token: tokenFor(2), want: http.StatusNotFound},
{name: "own tenant", token: tokenFor(1), want: http.StatusOK},
} {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, fileURL, nil)
if test.token != "" {
req.Header.Set("access-token", test.token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, test.want, w.Code)
})
}
}
func TestUploadHandler_ResponseStructure(t *testing.T) {
// Verify UploadResponse DTO structure matches expected JSON keys
resp := service.UploadResponse{
UploadID: 1,
UploadUUID: "abc-123",
OriginalName: "test.png",
FileType: "image",
MimeType: "image/png",
FileSize: 1024,
FileURL: "/uploads/account/1/test.png",
ThumbURL: "/uploads/account/1/test.png",
Status: "pending",
}
data, err := json.Marshal(resp)
assert.NoError(t, err)
var parsed map[string]interface{}
assert.NoError(t, json.Unmarshal(data, &parsed))
assert.Equal(t, float64(1), parsed["upload_id"])
assert.Equal(t, "abc-123", parsed["upload_uuid"])
assert.Equal(t, "test.png", parsed["original_name"])
assert.Equal(t, "image", parsed["file_type"])
assert.Equal(t, "image/png", parsed["mime_type"])
assert.Equal(t, float64(1024), parsed["file_size"])
assert.Equal(t, "/uploads/account/1/test.png", parsed["file_url"])
assert.Equal(t, "/uploads/account/1/test.png", parsed["thumb_url"])
assert.Equal(t, "pending", parsed["status"])
}