e2af482cc9
- Implement FileHandler with CRUD operations for files/directories - Add FileService with business logic and SHA-256 hashing - Create LocalStorage backend for filesystem persistence - Add database repository with pagination and name uniqueness constraints - Configure max upload size in storage settings - Include comprehensive tests for all layers
440 lines
12 KiB
Go
440 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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) Save(_ 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) 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) 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 {
|
|
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, 0)
|
|
|
|
return NewFileHandler(fileService)
|
|
}
|
|
|
|
func setupFileRouter(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
|
|
handler := setupFileHandler(t)
|
|
|
|
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_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)
|
|
}
|
|
}
|