package handler import ( "bytes" "context" "encoding/json" "errors" "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 failReads bool } 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} } if s.failReads { return io.NopCloser(&errorAfterReader{data: data, err: errors.New("download read failed")}), nil } 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 } type errorAfterReader struct { data []byte err error } func (r *errorAfterReader) Read(p []byte) (int, error) { if len(r.data) == 0 { return 0, r.err } n := copy(p, r.data) r.data = r.data[n:] return n, nil } var _ storage.StorageBackend = (*inMemStore)(nil) func setupFileHandler(t *testing.T) (*FileHandler, *inMemStore) { return setupFileHandlerWithMaxUploadSize(t, 0) } func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileHandler, *inMemStore) { 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), store } func setupFileRouter(t *testing.T) *gin.Engine { return setupFileRouterWithMaxUploadSize(t, 0) } func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.Engine { r, _ := setupFileRouterWithMaxUploadSizeAndStore(t, maxUploadSize) return r } func setupFileRouterWithMaxUploadSizeAndStore(t *testing.T, maxUploadSize int64) (*gin.Engine, *inMemStore) { t.Helper() handler, store := 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, store } 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_UploadUnexpectedFieldDoesNotEchoFieldName(t *testing.T) { r := setupFileRouter(t) body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, _ := writer.CreateFormField("secret_token") 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) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "unexpected multipart field") { t.Errorf("body = %s, want safe unexpected field message", rec.Body.String()) } if strings.Contains(rec.Body.String(), "secret_token") { t.Errorf("body leaks multipart field name: %s", rec.Body.String()) } } func TestFileHandler_UploadMalformedMultipartDoesNotLeakParserError(t *testing.T) { r := setupFileRouter(t) req := httptest.NewRequest(http.MethodPost, "/api/v1/files", strings.NewReader("not a valid multipart body")) req.Header.Set("Content-Type", "multipart/form-data; boundary=mygo-boundary") req.Header.Set("X-User-ID", "user1") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "invalid multipart form") { t.Errorf("body = %s, want safe invalid multipart message", rec.Body.String()) } if strings.Contains(rec.Body.String(), "NextPart") || strings.Contains(rec.Body.String(), "EOF") { t.Errorf("body leaks parser internals: %s", rec.Body.String()) } } 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_DownloadReadErrorAfterResponseStarted(t *testing.T) { r, store := setupFileRouterWithMaxUploadSizeAndStore(t, 0) body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, _ := writer.CreateFormFile("file", "download-error.txt") content := []byte("partial response") 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 if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil { t.Fatalf("unmarshal upload: %v", err) } store.failReads = true 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) } } 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) } }