b988b4b15e
- 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.
595 lines
15 KiB
Go
595 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/dhao2001/mygo/internal/model"
|
|
"github.com/dhao2001/mygo/internal/repository"
|
|
"github.com/dhao2001/mygo/internal/storage"
|
|
)
|
|
|
|
// assertAppError checks that err is an *model.AppError with the expected status.
|
|
func assertAppError(t *testing.T, err error, wantStatus int) bool {
|
|
t.Helper()
|
|
var ae *model.AppError
|
|
if !errors.As(err, &ae) {
|
|
t.Errorf("expected *model.AppError, got %T: %v", err, err)
|
|
return false
|
|
}
|
|
if ae.Status != wantStatus {
|
|
t.Errorf("status = %d, want %d; message = %q", ae.Status, wantStatus, ae.Message)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// memStorage is an in-memory implementation of storage.StorageBackend for tests.
|
|
type memStorage struct {
|
|
mu sync.RWMutex
|
|
files map[string][]byte
|
|
}
|
|
|
|
func newMemStorage() *memStorage {
|
|
return &memStorage{files: make(map[string][]byte)}
|
|
}
|
|
|
|
func (s *memStorage) SaveStaged(_ context.Context, path string, reader io.Reader) (int64, error) {
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
s.mu.Lock()
|
|
s.files[path] = data
|
|
s.mu.Unlock()
|
|
return int64(len(data)), nil
|
|
}
|
|
|
|
func (s *memStorage) PromoteStaged(_ context.Context, stagedPath, finalPath string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
data, ok := s.files[stagedPath]
|
|
if !ok {
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
s.files[finalPath] = data
|
|
delete(s.files, stagedPath)
|
|
return nil
|
|
}
|
|
|
|
func (s *memStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
|
s.mu.RLock()
|
|
data, ok := s.files[path]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return nil, io.ErrUnexpectedEOF
|
|
}
|
|
return io.NopCloser(bytes.NewReader(data)), nil
|
|
}
|
|
|
|
func (s *memStorage) DeleteStaged(ctx context.Context, path string) error {
|
|
return s.Delete(ctx, path)
|
|
}
|
|
|
|
func (s *memStorage) Delete(_ context.Context, path string) error {
|
|
s.mu.Lock()
|
|
delete(s.files, path)
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
var _ storage.StorageBackend = (*memStorage)(nil)
|
|
|
|
func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
|
|
return setupFileServiceWithStorageAndMaxUploadSize(t, 0)
|
|
}
|
|
|
|
func setupFileServiceWithStorageAndMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileService, *memStorage) {
|
|
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 := newMemStorage()
|
|
|
|
return NewFileService(fileRepo, store, maxUploadSize, nil), store // nil logger defaults to slog.Default()
|
|
}
|
|
|
|
func setupFileService(t *testing.T) *FileService {
|
|
svc, _ := setupFileServiceWithStorage(t)
|
|
return svc
|
|
}
|
|
|
|
func TestFileService_Upload(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "hello.txt", strings.NewReader("hello world"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
if info.ID == "" {
|
|
t.Fatal("file ID is empty")
|
|
}
|
|
if info.Name != "hello.txt" {
|
|
t.Errorf("Name = %q, want %q", info.Name, "hello.txt")
|
|
}
|
|
if info.Size != 11 {
|
|
t.Errorf("Size = %d, want 11", info.Size)
|
|
}
|
|
if info.UserID != "user1" {
|
|
t.Errorf("UserID = %q, want %q", info.UserID, "user1")
|
|
}
|
|
if info.IsDir {
|
|
t.Error("IsDir should be false")
|
|
}
|
|
// SHA-256 of "hello world" = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
|
|
expectedHash := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
|
if info.Hash != expectedHash {
|
|
t.Errorf("Hash = %q, want %q", info.Hash, expectedHash)
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadRejectsOversizedFileAndCleansStaging(t *testing.T) {
|
|
svc, store := setupFileServiceWithStorageAndMaxUploadSize(t, 5)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Upload(ctx, "user1", nil, "too-large.txt", strings.NewReader("123456"))
|
|
assertAppError(t, err, http.StatusRequestEntityTooLarge)
|
|
|
|
store.mu.RLock()
|
|
defer store.mu.RUnlock()
|
|
if len(store.files) != 0 {
|
|
t.Fatalf("storage files = %#v, want empty after oversized upload", store.files)
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadUnlimitedWhenMaxUploadSizeIsZero(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
content := strings.Repeat("a", 128)
|
|
info, err := svc.Upload(ctx, "user1", nil, "unlimited.txt", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
if info.Size != int64(len(content)) {
|
|
t.Errorf("Size = %d, want %d", info.Size, len(content))
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadIntoDirectory(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir, err := svc.CreateDir(ctx, "user1", nil, "docs")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir = %v", err)
|
|
}
|
|
|
|
info, err := svc.Upload(ctx, "user1", &dir.ID, "readme.txt", strings.NewReader("README"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
if info.ParentID == nil || *info.ParentID != dir.ID {
|
|
t.Error("file should be inside the docs directory")
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadInvalidName(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
invalidNames := []string{"", "foo/bar", "foo\\bar", ".", "..", "foo\x00bar"}
|
|
for _, name := range invalidNames {
|
|
_, err := svc.Upload(ctx, "user1", nil, name, strings.NewReader("data"))
|
|
if err == nil {
|
|
t.Errorf("expected error for name %q, got nil", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadDuplicateName(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("first"))
|
|
if err != nil {
|
|
t.Fatalf("first Upload = %v", err)
|
|
}
|
|
|
|
_, err = svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("second"))
|
|
if err == nil {
|
|
t.Fatal("expected duplicate name error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadNonexistentParent(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
fakeParentID := "nonexistent-id"
|
|
_, err := svc.Upload(ctx, "user1", &fakeParentID, "file.txt", strings.NewReader("data"))
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent parent, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFileService_UploadParentNotDirectory(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
file, err := svc.Upload(ctx, "user1", nil, "not-a-dir.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
_, err = svc.Upload(ctx, "user1", &file.ID, "child.txt", strings.NewReader("data"))
|
|
if err == nil {
|
|
t.Fatal("expected error for non-directory parent, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFileService_Download(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
content := "download test content"
|
|
info, err := svc.Upload(ctx, "user1", nil, "download.txt", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
reader, dlInfo, err := svc.Download(ctx, "user1", info.ID)
|
|
if err != nil {
|
|
t.Fatalf("Download = %v", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
if dlInfo.Name != "download.txt" {
|
|
t.Errorf("Name = %q, want %q", dlInfo.Name, "download.txt")
|
|
}
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll = %v", err)
|
|
}
|
|
if string(data) != content {
|
|
t.Errorf("content = %q, want %q", string(data), content)
|
|
}
|
|
}
|
|
|
|
func TestFileService_DownloadForbidden(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
_, _, err = svc.Download(ctx, "user2", info.ID)
|
|
assertAppError(t, err, http.StatusForbidden)
|
|
}
|
|
|
|
func TestFileService_DownloadDirectory(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir, err := svc.CreateDir(ctx, "user1", nil, "mydir")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir = %v", err)
|
|
}
|
|
|
|
_, _, err = svc.Download(ctx, "user1", dir.ID)
|
|
if err == nil {
|
|
t.Fatal("expected error downloading directory, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFileService_Get(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
created, err := svc.Upload(ctx, "user1", nil, "info.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
info, err := svc.Get(ctx, "user1", created.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get = %v", err)
|
|
}
|
|
if info.ID != created.ID {
|
|
t.Errorf("ID = %q, want %q", info.ID, created.ID)
|
|
}
|
|
}
|
|
|
|
func TestFileService_GetNotFound(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Get(ctx, "user1", "nonexistent")
|
|
assertAppError(t, err, http.StatusNotFound)
|
|
}
|
|
|
|
func TestFileService_GetForbidden(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
_, err = svc.Get(ctx, "user2", info.ID)
|
|
assertAppError(t, err, http.StatusForbidden)
|
|
}
|
|
|
|
func TestFileService_List(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir, err := svc.CreateDir(ctx, "user1", nil, "dir")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir = %v", err)
|
|
}
|
|
_, err = svc.Upload(ctx, "user1", nil, "root.txt", strings.NewReader("root"))
|
|
if err != nil {
|
|
t.Fatalf("Upload root = %v", err)
|
|
}
|
|
_, err = svc.Upload(ctx, "user1", &dir.ID, "nested.txt", strings.NewReader("nested"))
|
|
if err != nil {
|
|
t.Fatalf("Upload nested = %v", err)
|
|
}
|
|
|
|
// List root.
|
|
rootList, err := svc.List(ctx, "user1", nil, 0, 50)
|
|
if err != nil {
|
|
t.Fatalf("List root = %v", err)
|
|
}
|
|
if rootList.Total != 2 {
|
|
t.Errorf("root total = %d, want 2", rootList.Total)
|
|
}
|
|
|
|
// List inside directory.
|
|
dirList, err := svc.List(ctx, "user1", &dir.ID, 0, 50)
|
|
if err != nil {
|
|
t.Fatalf("List dir = %v", err)
|
|
}
|
|
if dirList.Total != 1 {
|
|
t.Errorf("dir total = %d, want 1", dirList.Total)
|
|
}
|
|
}
|
|
|
|
func TestFileService_Update(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "oldname.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
updated, err := svc.Update(ctx, "user1", info.ID, "newname.txt", nil)
|
|
if err != nil {
|
|
t.Fatalf("Update = %v", err)
|
|
}
|
|
if updated.Name != "newname.txt" {
|
|
t.Errorf("Name = %q, want %q", updated.Name, "newname.txt")
|
|
}
|
|
}
|
|
|
|
func TestFileService_UpdateMove(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir1, err := svc.CreateDir(ctx, "user1", nil, "dir1")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir 1 = %v", err)
|
|
}
|
|
dir2, err := svc.CreateDir(ctx, "user1", nil, "dir2")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir 2 = %v", err)
|
|
}
|
|
|
|
info, err := svc.Upload(ctx, "user1", &dir1.ID, "move.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
updated, err := svc.Update(ctx, "user1", info.ID, "", &dir2.ID)
|
|
if err != nil {
|
|
t.Fatalf("Update = %v", err)
|
|
}
|
|
if updated.ParentID == nil || *updated.ParentID != dir2.ID {
|
|
t.Error("file should be moved to dir2")
|
|
}
|
|
}
|
|
|
|
func TestFileService_Delete(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "delete-me.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
_, err = svc.Get(ctx, "user1", info.ID)
|
|
assertAppError(t, err, http.StatusNotFound)
|
|
}
|
|
|
|
func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir, err := svc.CreateDir(ctx, "user1", nil, "dir")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir = %v", err)
|
|
}
|
|
_, err = svc.Upload(ctx, "user1", &dir.ID, "child.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
err = svc.Delete(ctx, "user1", dir.ID)
|
|
if err == nil {
|
|
t.Fatal("expected error deleting non-empty directory, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFileService_DeleteForbidden(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "file.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
err = svc.Delete(ctx, "user2", info.ID)
|
|
assertAppError(t, err, http.StatusForbidden)
|
|
}
|
|
|
|
func TestFileService_CreateDir(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir, err := svc.CreateDir(ctx, "user1", nil, "mydir")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir = %v", err)
|
|
}
|
|
if !dir.IsDir {
|
|
t.Error("IsDir should be true")
|
|
}
|
|
if dir.Name != "mydir" {
|
|
t.Errorf("Name = %q, want %q", dir.Name, "mydir")
|
|
}
|
|
}
|
|
|
|
func TestFileService_CreateDirDuplicateName(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.CreateDir(ctx, "user1", nil, "dup")
|
|
if err != nil {
|
|
t.Fatalf("first CreateDir = %v", err)
|
|
}
|
|
|
|
_, err = svc.CreateDir(ctx, "user1", nil, "dup")
|
|
if err == nil {
|
|
t.Fatal("expected duplicate name error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFileService_VerifyParentForbidden(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
dir, err := svc.CreateDir(ctx, "user1", nil, "dir")
|
|
if err != nil {
|
|
t.Fatalf("CreateDir = %v", err)
|
|
}
|
|
|
|
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
|
|
assertAppError(t, err, http.StatusForbidden)
|
|
}
|
|
|
|
func TestFileService_SoftDelete(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "soft-delete.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
_, err = svc.Get(ctx, "user1", info.ID)
|
|
assertAppError(t, err, http.StatusNotFound)
|
|
}
|
|
|
|
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
|
svc, store := setupFileServiceWithStorage(t)
|
|
ctx := context.Background()
|
|
|
|
content := "keep me after soft-delete"
|
|
info, err := svc.Upload(ctx, "user1", nil, "keep.txt", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
storagePath := "data/user1/" + info.ID
|
|
reader, err := store.Open(ctx, storagePath)
|
|
if err != nil {
|
|
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatalf("read storage: %v", err)
|
|
}
|
|
if string(data) != content {
|
|
t.Errorf("storage content = %q, want %q", string(data), content)
|
|
}
|
|
}
|
|
|
|
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "idem.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
|
t.Fatalf("first Delete = %v", err)
|
|
}
|
|
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
|
t.Fatalf("second Delete should be idempotent, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
|
svc := setupFileService(t)
|
|
ctx := context.Background()
|
|
|
|
info, err := svc.Upload(ctx, "user1", nil, "forbidden.txt", strings.NewReader("data"))
|
|
if err != nil {
|
|
t.Fatalf("Upload = %v", err)
|
|
}
|
|
|
|
err = svc.Delete(ctx, "user2", info.ID)
|
|
assertAppError(t, err, http.StatusForbidden)
|
|
}
|