Add file management API with local storage backend

- 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
This commit is contained in:
2026-06-24 14:08:57 +08:00
parent eaa31efd64
commit e2af482cc9
17 changed files with 1815 additions and 15 deletions
+376
View File
@@ -0,0 +1,376 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/google/uuid"
"github.com/dhao2001/mygo/internal/model"
"github.com/dhao2001/mygo/internal/repository"
"github.com/dhao2001/mygo/internal/storage"
)
// FileInfo is the public representation of a file or directory.
type FileInfo struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ParentID *string `json:"parent_id"`
Name string `json:"name"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
IsDir bool `json:"is_dir"`
Hash string `json:"hash,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FileList is a paginated list of files.
type FileList struct {
Files []FileInfo `json:"files"`
Total int64 `json:"total"`
}
// FileService handles file management business logic.
type FileService struct {
fileRepo repository.FileRepository
storage storage.StorageBackend
maxUploadSize int64
}
// NewFileService creates a FileService.
func NewFileService(
fileRepo repository.FileRepository,
storage storage.StorageBackend,
maxUploadSize int64,
) *FileService {
return &FileService{
fileRepo: fileRepo,
storage: storage,
maxUploadSize: maxUploadSize,
}
}
// Upload stores a file's content and creates its metadata record.
func (s *FileService) Upload(ctx context.Context, userID string, parentID *string, fileName string, reader io.Reader, clientMime string) (*FileInfo, error) {
if err := validateFileName(fileName); err != nil {
return nil, err
}
if parentID != nil {
if err := s.verifyParent(ctx, userID, *parentID); err != nil {
return nil, err
}
}
// Check for name conflicts in the target directory.
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, fileName); err == nil {
return nil, fmt.Errorf("a file with this name already exists in this directory")
} else if !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("check name conflict: %w", err)
}
// Limit the reader if a max upload size is configured.
if s.maxUploadSize > 0 {
reader = io.LimitReader(reader, s.maxUploadSize+1)
}
// Detect MIME type when the client does not provide a meaningful one.
mimeType := clientMime
if mimeType == "" || mimeType == "application/octet-stream" {
// Read the first 512 bytes for detection, then replay them.
head := make([]byte, 512)
n, readErr := io.ReadFull(reader, head)
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
return nil, fmt.Errorf("read for mime detection: %w", readErr)
}
head = head[:n]
mimeType = mimetype.Detect(head).String()
reader = io.MultiReader(bytes.NewReader(head), reader)
}
fileID := uuid.NewString()
storagePath := fmt.Sprintf("%s/%s", userID, fileID)
// Compute SHA-256 hash while writing to storage.
hasher := sha256.New()
teeReader := io.TeeReader(reader, hasher)
written, err := s.storage.Save(ctx, storagePath, teeReader)
if err != nil {
return nil, fmt.Errorf("save file: %w", err)
}
if s.maxUploadSize > 0 && written > s.maxUploadSize {
// Clean up the oversized file.
_ = s.storage.Delete(ctx, storagePath)
return nil, fmt.Errorf("file size exceeds the maximum allowed size of %d bytes", s.maxUploadSize)
}
file := &model.File{
ID: fileID,
UserID: userID,
ParentID: parentID,
Name: fileName,
Size: written,
MimeType: mimeType,
StoragePath: storagePath,
Hash: hex.EncodeToString(hasher.Sum(nil)),
IsDir: false,
}
if err := s.fileRepo.Create(ctx, file); err != nil {
// Compensate: remove the stored file.
_ = s.storage.Delete(ctx, storagePath)
if errors.Is(err, model.ErrDuplicate) {
return nil, fmt.Errorf("a file with this name already exists in this directory")
}
return nil, fmt.Errorf("create file record: %w", err)
}
return modelToFileInfo(file), nil
}
// Download returns a reader for the file's content and its metadata.
// The caller must close the returned ReadCloser.
func (s *FileService) Download(ctx context.Context, userID, fileID string) (io.ReadCloser, *FileInfo, error) {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return nil, nil, err
}
if file.IsDir {
return nil, nil, fmt.Errorf("cannot download a directory")
}
reader, err := s.storage.Open(ctx, file.StoragePath)
if err != nil {
return nil, nil, fmt.Errorf("open file: %w", err)
}
return reader, modelToFileInfo(file), nil
}
// Get returns metadata for a single file or directory.
func (s *FileService) Get(ctx context.Context, userID, fileID string) (*FileInfo, error) {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return nil, err
}
return modelToFileInfo(file), nil
}
// List returns the contents of a directory with pagination.
func (s *FileService) List(ctx context.Context, userID string, parentID *string, offset, limit int) (*FileList, error) {
if parentID != nil {
if err := s.verifyParent(ctx, userID, *parentID); err != nil {
return nil, err
}
}
files, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, parentID, offset, limit)
if err != nil {
return nil, fmt.Errorf("list files: %w", err)
}
infos := make([]FileInfo, 0, len(files))
for i := range files {
infos = append(infos, *modelToFileInfo(&files[i]))
}
return &FileList{Files: infos, Total: total}, nil
}
// Update renames or moves a file or directory.
func (s *FileService) Update(ctx context.Context, userID, fileID string, newName string, newParentID *string) (*FileInfo, error) {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return nil, err
}
// Validate new name if provided.
if newName != "" {
if err := validateFileName(newName); err != nil {
return nil, err
}
file.Name = newName
}
// If parent is changing, verify the new parent.
if newParentID != nil {
if *newParentID == fileID {
return nil, fmt.Errorf("cannot move a directory into itself")
}
if err := s.verifyParent(ctx, userID, *newParentID); err != nil {
return nil, err
}
file.ParentID = newParentID
}
// Check for name conflicts in the target directory.
if newName != "" || newParentID != nil {
conflict, err := s.fileRepo.FindByNameAndParent(ctx, userID, file.ParentID, file.Name)
if err == nil && conflict.ID != fileID {
return nil, fmt.Errorf("a file with this name already exists in the target directory")
} else if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("check name conflict: %w", err)
}
}
if err := s.fileRepo.Update(ctx, file); err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, fmt.Errorf("a file with this name already exists in the target directory")
}
return nil, fmt.Errorf("update file: %w", err)
}
return modelToFileInfo(file), nil
}
// Delete removes a file or (empty) directory.
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
return err
}
// Directories must be empty before deletion.
if file.IsDir {
_, total, err := s.fileRepo.FindByParentIDPaginated(ctx, userID, &fileID, 0, 1)
if err != nil {
return fmt.Errorf("check directory contents: %w", err)
}
if total > 0 {
return fmt.Errorf("directory is not empty")
}
}
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
return fmt.Errorf("delete file record: %w", err)
}
// Remove content from storage (directories have no stored content).
if !file.IsDir {
if err := s.storage.Delete(ctx, file.StoragePath); err != nil {
// Log but don't fail — the DB record is already gone.
// A periodic cleanup job can handle orphaned files later.
_ = err
}
}
return nil
}
// CreateDir creates a new directory.
func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *string, dirName string) (*FileInfo, error) {
if err := validateFileName(dirName); err != nil {
return nil, err
}
if parentID != nil {
if err := s.verifyParent(ctx, userID, *parentID); err != nil {
return nil, err
}
}
// Check for name conflicts in the target directory.
if _, err := s.fileRepo.FindByNameAndParent(ctx, userID, parentID, dirName); err == nil {
return nil, fmt.Errorf("a file with this name already exists in this directory")
} else if !errors.Is(err, model.ErrNotFound) {
return nil, fmt.Errorf("check name conflict: %w", err)
}
dir := &model.File{
ID: uuid.NewString(),
UserID: userID,
ParentID: parentID,
Name: dirName,
IsDir: true,
}
if err := s.fileRepo.Create(ctx, dir); err != nil {
if errors.Is(err, model.ErrDuplicate) {
return nil, fmt.Errorf("a file with this name already exists in this directory")
}
return nil, fmt.Errorf("create directory record: %w", err)
}
return modelToFileInfo(dir), nil
}
// getOwnedFile fetches a file and verifies ownership.
func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (*model.File, error) {
file, err := s.fileRepo.FindByID(ctx, fileID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrNotFound
}
return nil, fmt.Errorf("find file: %w", err)
}
if file.UserID != userID {
return nil, model.ErrForbidden
}
return file, nil
}
// verifyParent checks that a parent directory exists, belongs to the user, and is a directory.
func (s *FileService) verifyParent(ctx context.Context, userID, parentID string) error {
parent, err := s.fileRepo.FindByID(ctx, parentID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("parent directory not found")
}
return fmt.Errorf("find parent: %w", err)
}
if parent.UserID != userID {
return model.ErrForbidden
}
if !parent.IsDir {
return fmt.Errorf("parent is not a directory")
}
return nil
}
// validateFileName rejects names that are empty, contain path separators,
// null bytes, or are reserved names "." and "..".
func validateFileName(name string) error {
if name == "" {
return fmt.Errorf("file name must not be empty")
}
if strings.ContainsAny(name, "/\\\x00") {
return fmt.Errorf("file name contains invalid characters")
}
if name == "." || name == ".." {
return fmt.Errorf("file name is reserved: %s", name)
}
return nil
}
// modelToFileInfo converts a model.File to a FileInfo DTO.
func modelToFileInfo(f *model.File) *FileInfo {
return &FileInfo{
ID: f.ID,
UserID: f.UserID,
ParentID: f.ParentID,
Name: f.Name,
Size: f.Size,
MimeType: f.MimeType,
IsDir: f.IsDir,
Hash: f.Hash,
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
}
}
+458
View File
@@ -0,0 +1,458 @@
package service
import (
"bytes"
"context"
"io"
"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"
)
// 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) Save(_ 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) 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) Delete(_ context.Context, path string) error {
s.mu.Lock()
delete(s.files, path)
s.mu.Unlock()
return nil
}
var _ storage.StorageBackend = (*memStorage)(nil)
func setupFileService(t *testing.T) *FileService {
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, 0) // 0 = unlimited
}
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_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)
if err != model.ErrForbidden {
t.Errorf("expected ErrForbidden, got %v", err)
}
}
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")
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
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)
if err != model.ErrForbidden {
t.Errorf("expected ErrForbidden, got %v", err)
}
}
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)
if err != model.ErrNotFound {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
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)
if err != model.ErrForbidden {
t.Errorf("expected ErrForbidden, got %v", err)
}
}
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"), "")
if err != model.ErrForbidden {
t.Errorf("expected ErrForbidden, got %v", err)
}
}