refactor: add AppError helpers and harden upload/download error handling
- refactor: replace inline AppError literals with model.New*Error constructors in service and handler layers - feat: PromoteStaged falls back to copy/delete when os.Rename returns EXDEV (cross-device link) - fix: upload error messages no longer leak internal multipart field names or parser implementation details - fix: upload size violations are converted to domain error ErrUploadTooLarge at the handler boundary - fix: download logs error and size mismatch when io.Copy fails or writes fewer bytes than expected - test: add tests for field name leak prevention, parser error sanitization, read errors after response start, and EXDEV fallback
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -23,7 +24,8 @@ import (
|
||||
|
||||
// inMemStore implements storage.StorageBackend with an in-memory map.
|
||||
type inMemStore struct {
|
||||
files map[string][]byte
|
||||
files map[string][]byte
|
||||
failReads bool
|
||||
}
|
||||
|
||||
func newInMemStore() *inMemStore {
|
||||
@@ -54,6 +56,9 @@ func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -70,13 +75,27 @@ 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 {
|
||||
func setupFileHandler(t *testing.T) (*FileHandler, *inMemStore) {
|
||||
return setupFileHandlerWithMaxUploadSize(t, 0)
|
||||
}
|
||||
|
||||
func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) *FileHandler {
|
||||
func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) (*FileHandler, *inMemStore) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -91,7 +110,7 @@ func setupFileHandlerWithMaxUploadSize(t *testing.T, maxUploadSize int64) *FileH
|
||||
store := newInMemStore()
|
||||
fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil)
|
||||
|
||||
return NewFileHandler(fileService)
|
||||
return NewFileHandler(fileService), store
|
||||
}
|
||||
|
||||
func setupFileRouter(t *testing.T) *gin.Engine {
|
||||
@@ -99,9 +118,14 @@ func setupFileRouter(t *testing.T) *gin.Engine {
|
||||
}
|
||||
|
||||
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 := setupFileHandlerWithMaxUploadSize(t, maxUploadSize)
|
||||
handler, store := setupFileHandlerWithMaxUploadSize(t, maxUploadSize)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -125,7 +149,7 @@ func setupFileRouterWithMaxUploadSize(t *testing.T, maxUploadSize int64) *gin.En
|
||||
files.DELETE("/:id", handler.Delete)
|
||||
}
|
||||
|
||||
return r
|
||||
return r, store
|
||||
}
|
||||
|
||||
func TestFileHandler_Upload(t *testing.T) {
|
||||
@@ -234,6 +258,52 @@ func TestFileHandler_UploadNoFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -380,6 +450,42 @@ func TestFileHandler_Download(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user