28e17a5b08
- 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
260 lines
6.9 KiB
Go
260 lines
6.9 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
)
|
|
|
|
func TestSaveAndOpen(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
content := "hello, mygo storage"
|
|
written, err := store.SaveStaged(ctx, "staging/test/hello.txt", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("SaveStaged: %v", err)
|
|
}
|
|
if written != int64(len(content)) {
|
|
t.Errorf("written = %d, want %d", written, len(content))
|
|
}
|
|
|
|
if err := store.PromoteStaged(ctx, "staging/test/hello.txt", "data/test/hello.txt"); err != nil {
|
|
t.Fatalf("PromoteStaged: %v", err)
|
|
}
|
|
|
|
reader, err := store.Open(ctx, "data/test/hello.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
got, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if string(got) != content {
|
|
t.Errorf("content = %q, want %q", string(got), content)
|
|
}
|
|
|
|
// Verify the file exists on disk under the base path.
|
|
expectedPath := dir + "/data/test/hello.txt"
|
|
if _, err := os.Stat(expectedPath); err != nil {
|
|
t.Errorf("file not found on disk at %s: %v", expectedPath, err)
|
|
}
|
|
}
|
|
|
|
func TestDelete(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
_, err = store.SaveStaged(ctx, "staging/to_delete.txt", strings.NewReader("delete me"))
|
|
if err != nil {
|
|
t.Fatalf("SaveStaged: %v", err)
|
|
}
|
|
if err := store.PromoteStaged(ctx, "staging/to_delete.txt", "data/to_delete.txt"); err != nil {
|
|
t.Fatalf("PromoteStaged: %v", err)
|
|
}
|
|
|
|
if err := store.Delete(ctx, "data/to_delete.txt"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
_, err = store.Open(ctx, "data/to_delete.txt")
|
|
if err == nil {
|
|
t.Error("Open should fail after delete")
|
|
}
|
|
}
|
|
|
|
func TestDeleteIdempotent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Deleting a non-existent file should not error.
|
|
if err := store.Delete(ctx, "nonexistent.txt"); err != nil {
|
|
t.Errorf("Delete nonexistent should be idempotent, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalRejected(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
_, err = store.SaveStaged(ctx, "../escape.txt", strings.NewReader("malicious"))
|
|
if err == nil {
|
|
t.Error("SaveStaged should reject path traversal")
|
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
|
t.Errorf("expected path traversal error, got: %v", err)
|
|
}
|
|
|
|
err = store.PromoteStaged(ctx, "../escape.txt", "data/escape.txt")
|
|
if err == nil {
|
|
t.Error("PromoteStaged should reject staged path traversal")
|
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
|
t.Errorf("expected path traversal error, got: %v", err)
|
|
}
|
|
|
|
err = store.PromoteStaged(ctx, "staging/escape.txt", "../escape.txt")
|
|
if err == nil {
|
|
t.Error("PromoteStaged should reject final path traversal")
|
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
|
t.Errorf("expected path traversal error, got: %v", err)
|
|
}
|
|
|
|
_, err = store.Open(ctx, "../escape.txt")
|
|
if err == nil {
|
|
t.Error("Open should reject path traversal")
|
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
|
t.Errorf("expected path traversal error, got: %v", err)
|
|
}
|
|
|
|
err = store.Delete(ctx, "../escape.txt")
|
|
if err == nil {
|
|
t.Error("Delete should reject path traversal")
|
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
|
t.Errorf("expected path traversal error, got: %v", err)
|
|
}
|
|
|
|
err = store.DeleteStaged(ctx, "../escape.txt")
|
|
if err == nil {
|
|
t.Error("DeleteStaged should reject path traversal")
|
|
} else if !strings.Contains(err.Error(), "path traversal") {
|
|
t.Errorf("expected path traversal error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStagedPathsRequireStagingPrefix(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
_, err = store.SaveStaged(ctx, "data/not-staged.txt", strings.NewReader("bad"))
|
|
if err == nil {
|
|
t.Fatal("SaveStaged should reject non-staging path")
|
|
}
|
|
|
|
err = store.DeleteStaged(ctx, "data/not-staged.txt")
|
|
if err == nil {
|
|
t.Fatal("DeleteStaged should reject non-staging path")
|
|
}
|
|
}
|
|
|
|
func TestPromoteRequiresDataFinalPrefix(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
_, err = store.SaveStaged(ctx, "staging/promote.txt", strings.NewReader("content"))
|
|
if err != nil {
|
|
t.Fatalf("SaveStaged: %v", err)
|
|
}
|
|
|
|
err = store.PromoteStaged(ctx, "staging/promote.txt", "other/promote.txt")
|
|
if err == nil {
|
|
t.Fatal("PromoteStaged should reject non-data final path")
|
|
}
|
|
}
|
|
|
|
func TestPromoteStagedFallsBackToCopyOnEXDEV(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
content := "copy across filesystems"
|
|
_, err = store.SaveStaged(ctx, "staging/cross-device.txt", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("SaveStaged: %v", err)
|
|
}
|
|
|
|
originalRename := renameLocalFile
|
|
renameLocalFile = func(_, _ string) error {
|
|
return &os.LinkError{Op: "rename", Err: syscall.EXDEV}
|
|
}
|
|
t.Cleanup(func() {
|
|
renameLocalFile = originalRename
|
|
})
|
|
|
|
if err := store.PromoteStaged(ctx, "staging/cross-device.txt", "data/cross-device.txt"); err != nil {
|
|
t.Fatalf("PromoteStaged: %v", err)
|
|
}
|
|
|
|
reader, err := store.Open(ctx, "data/cross-device.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open promoted file: %v", err)
|
|
}
|
|
got, err := io.ReadAll(reader)
|
|
reader.Close()
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if string(got) != content {
|
|
t.Errorf("content = %q, want %q", string(got), content)
|
|
}
|
|
|
|
if _, err := os.Stat(dir + "/staging/cross-device.txt"); !os.IsNotExist(err) {
|
|
t.Errorf("staged file should be removed after fallback promote, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenMissingFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewLocalStorage(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewLocalStorage: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
_, err = store.Open(ctx, "no/such/file.txt")
|
|
if err == nil {
|
|
t.Error("Open should error for missing file")
|
|
} else if !strings.Contains(err.Error(), "not found on disk") {
|
|
t.Errorf("expected 'not found on disk' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIsSubPath_SameDirectory(t *testing.T) {
|
|
if !isSubPath("/base", "/base") {
|
|
t.Error("isSubPath should return true when base == target")
|
|
}
|
|
}
|
|
|
|
func TestIsSubPath_ShortChildName(t *testing.T) {
|
|
if !isSubPath("/base", "/base/a") {
|
|
t.Error("isSubPath should return true for single-char child path")
|
|
}
|
|
}
|
|
|
|
func TestIsSubPath_DotDotPrefixFilename(t *testing.T) {
|
|
if !isSubPath("/base", "/base/..foo") {
|
|
t.Error("isSubPath should return true for filename starting with ..")
|
|
}
|
|
}
|