Files
mygo/internal/handler/file_test.go
T
ld b988b4b15e fix(file)!: stream uploads through staged storage
- fix: replace multipart form parsing with streaming multipart reads and
  apply request body limits when max_upload_size is configured.
- refactor: route uploads through staging paths before promotion to
  long-term data paths, keeping incomplete uploads out of durable
  storage records.
- test: cover oversized uploads, unlimited uploads, staged cleanup, and
  local storage promotion boundaries.
- docs: document the staged upload model and multipart parent_id query
  parameter.
2026-07-05 17:18:19 +08:00

523 lines
14 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/service"
"github.com/dhao2001/mygo/internal/storage"
)
// inMemStore implements storage.StorageBackend with an in-memory map.
type inMemStore struct {
files map[string][]byte
}
func newInMemStore() *inMemStore {
return &inMemStore{files: make(map[string][]byte)}
}
func (s *inMemStore) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
data, err := io.ReadAll(reader)
if err != nil {
return 0, err
}
s.files[path] = data
return int64(len(data)), nil
}
func (s *inMemStore) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
data, ok := s.files[stagedPath]
if !ok {
return io.ErrUnexpectedEOF
}
s.files[finalPath] = data
delete(s.files, stagedPath)
return nil
}
func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) {
data, ok := s.files[path]
if !ok {
return nil, &fsNotFoundError{path}
}
return io.NopCloser(bytes.NewReader(data)), nil
}
func (s *inMemStore) DeleteStaged(ctx context.Context, path string) error {
return s.Delete(ctx, path)
}
func (s *inMemStore) Delete(_ context.Context, path string) error {
delete(s.files, path)
return nil
}
type fsNotFoundError struct{ path string }
func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e.path }
var _ storage.StorageBackend = (*inMemStore)(nil)
func setupFileHandler(t *testing.T) *FileHandler {
return setupFileHandlerWithMaxUploadSize(t, 0)
}
func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) *FileHandler {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.File{}); err != nil {
t.Fatalf("migrate: %v", err)
}
fileRepo := repository.NewFileRepository(db)
store := newInMemStore()
fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil)
return NewFileHandler(fileService)
}
func setupFileRouter(t *testing.T) *gin.Engine {
return setupFileRouterWithMaxUploadSize(t, 0)
}
func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.Engine {
t.Helper()
handler := setupFileHandlerWithMaxUploadSize(t, maxUploadSize)
gin.SetMode(gin.TestMode)
r := gin.New()
// Middleware that injects user_id from header (simulates AuthRequired).
r.Use(func(c *gin.Context) {
userID := c.GetHeader("X-User-ID")
if userID != "" {
c.Set("user_id", userID)
}
c.Next()
})
files := r.Group("/api/v1/files")
{
files.GET("", handler.List)
files.POST("", handler.Upload)
files.GET("/:id", handler.Get)
files.GET("/:id/content", handler.Download)
files.PUT("/:id", handler.Update)
files.DELETE("/:id", handler.Delete)
}
return r
}
func TestFileHandler_Upload(t *testing.T) {
r := setupFileRouter(t)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "test.txt")
part.Write([]byte("hello upload"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
}
var info service.FileInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if info.Name != "test.txt" {
t.Errorf("Name = %q, want %q", info.Name, "test.txt")
}
}
func TestFileHandler_UploadRejectsOversizedFile(t *testing.T) {
r := setupFileRouterWithMaxUploadSize(t, 5)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "too-large.txt")
part.Write([]byte("123456"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
}
}
func TestFileHandler_UploadAllowsExactMaxUploadSize(t *testing.T) {
r := setupFileRouterWithMaxUploadSize(t, 5)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "exact.txt")
part.Write([]byte("12345"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
}
}
func TestFileHandler_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) {
r := setupFileRouter(t)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "unlimited.txt")
part.Write([]byte(strings.Repeat("a", 128)))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
}
}
func TestFileHandler_UploadNoFile(t *testing.T) {
r := setupFileRouter(t)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestFileHandler_CreateDir(t *testing.T) {
r := setupFileRouter(t)
body, _ := json.Marshal(gin.H{"name": "mydir"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
}
var info service.FileInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !info.IsDir {
t.Error("IsDir should be true")
}
}
func TestFileHandler_List(t *testing.T) {
r := setupFileRouter(t)
// Upload a file first.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "file1.txt")
part.Write([]byte("content1"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// List files.
req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil)
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
}
var list service.FileList
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if list.Total != 1 {
t.Errorf("Total = %d, want 1", list.Total)
}
}
func TestFileHandler_Get(t *testing.T) {
r := setupFileRouter(t)
// Upload a file first.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "info.txt")
part.Write([]byte("file info content"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var uploaded service.FileInfo
json.Unmarshal(rec.Body.Bytes(), &uploaded)
// Get metadata.
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var info service.FileInfo
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if info.ID != uploaded.ID {
t.Errorf("ID = %q, want %q", info.ID, uploaded.ID)
}
}
func TestFileHandler_GetNotFound(t *testing.T) {
r := setupFileRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/nonexistent", nil)
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestFileHandler_Download(t *testing.T) {
r := setupFileRouter(t)
// Upload a file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "download.txt")
content := []byte("download me please")
part.Write(content)
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var uploaded service.FileInfo
json.Unmarshal(rec.Body.Bytes(), &uploaded)
// Download.
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil)
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !bytes.Equal(rec.Body.Bytes(), content) {
t.Errorf("content = %q, want %q", rec.Body.String(), content)
}
if rec.Header().Get("Content-Disposition") == "" {
t.Error("Content-Disposition header should be set")
}
}
func TestFileHandler_Update(t *testing.T) {
r := setupFileRouter(t)
// Upload a file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "oldname.txt")
part.Write([]byte("content"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var uploaded service.FileInfo
json.Unmarshal(rec.Body.Bytes(), &uploaded)
// Rename.
updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"})
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
}
var updated service.FileInfo
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if updated.Name != "newname.txt" {
t.Errorf("Name = %q, want %q", updated.Name, "newname.txt")
}
}
func TestFileHandler_UpdateNoFields(t *testing.T) {
r := setupFileRouter(t)
// Upload a file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "file.txt")
part.Write([]byte("content"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var uploaded service.FileInfo
json.Unmarshal(rec.Body.Bytes(), &uploaded)
updateBody, _ := json.Marshal(gin.H{})
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestFileHandler_Delete(t *testing.T) {
r := setupFileRouter(t)
// Upload a file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "delete-me.txt")
part.Write([]byte("content"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var uploaded service.FileInfo
json.Unmarshal(rec.Body.Bytes(), &uploaded)
// Delete.
req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil)
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
}
// Verify gone.
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
req.Header.Set("X-User-ID", "user1")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status after delete = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestFileHandler_ForbiddenAccess(t *testing.T) {
r := setupFileRouter(t)
// Upload as user1.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("file", "private.txt")
part.Write([]byte("secret"))
writer.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-User-ID", "user1")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var uploaded service.FileInfo
json.Unmarshal(rec.Body.Bytes(), &uploaded)
// Try to access as user2.
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
req.Header.Set("X-User-ID", "user2")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}