package storage import ( "context" "io" "net/http" "net/http/httptest" "os" "path/filepath" "testing" ) func TestLocalStorage_Upload(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) os.Unsetenv("LOCAL_UPLOAD_BASE_URL") // No LOCAL_UPLOAD_BASE_URL set - should return relative path store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } ctx := context.Background() data := []byte("hello world") contentType := "text/plain" filename := "test.txt" link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename) if err != nil { t.Fatalf("Upload failed: %v", err) } expectedLink := "/uploads/test-key.txt" if link != expectedLink { t.Errorf("link = %q, want %q", link, expectedLink) } filePath := filepath.Join(tmpDir, "test-key.txt") stored, err := os.ReadFile(filePath) if err != nil { t.Fatalf("failed to read uploaded file: %v", err) } if string(stored) != string(data) { t.Errorf("stored data = %q, want %q", stored, data) } } func TestLocalStorage_Upload_WithBaseURL(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080") store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } ctx := context.Background() data := []byte("hello world") contentType := "text/plain" filename := "test.txt" link, err := store.Upload(ctx, "test-key.txt", data, contentType, filename) if err != nil { t.Fatalf("Upload failed: %v", err) } // When LOCAL_UPLOAD_BASE_URL is set, should return full URL expectedLink := "http://localhost:8080/uploads/test-key.txt" if link != expectedLink { t.Errorf("link = %q, want %q", link, expectedLink) } filePath := filepath.Join(tmpDir, "test-key.txt") stored, err := os.ReadFile(filePath) if err != nil { t.Fatalf("failed to read uploaded file: %v", err) } if string(stored) != string(data) { t.Errorf("stored data = %q, want %q", stored, data) } } func TestLocalStorage_Delete(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } ctx := context.Background() data := []byte("hello world") _, err := store.Upload(ctx, "delete-me.txt", data, "text/plain", "delete-me.txt") if err != nil { t.Fatalf("Upload failed: %v", err) } filePath := filepath.Join(tmpDir, "delete-me.txt") if _, err := os.ReadFile(filePath); err != nil { t.Fatalf("file should exist: %v", err) } store.Delete(ctx, "delete-me.txt") if _, err := os.ReadFile(filePath); !os.IsNotExist(err) { t.Errorf("file should be deleted") } } func TestLocalStorage_KeyFromURL(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) // No baseURL set store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } tests := []struct { name string rawURL string expected string }{ {"local URL format", "/uploads/abc123.png", "abc123.png"}, {"local URL with subdir", "/uploads/2024/01/image.jpg", "2024/01/image.jpg"}, {"local URL with workspace prefix", "/uploads/workspaces/ws-123/abc.png", "workspaces/ws-123/abc.png"}, {"just filename", "abc123.png", "abc123.png"}, {"full path", "/some/path/to/file.pdf", "file.pdf"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := store.KeyFromURL(tc.rawURL) if got != tc.expected { t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected) } }) } } func TestLocalStorage_KeyFromURL_WithBaseURL(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) t.Setenv("LOCAL_UPLOAD_BASE_URL", "http://localhost:8080") store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } tests := []struct { name string rawURL string expected string }{ {"full URL format", "http://localhost:8080/uploads/abc123.png", "abc123.png"}, {"full URL with subdir", "http://localhost:8080/uploads/2024/01/image.jpg", "2024/01/image.jpg"}, {"local URL format still works", "/uploads/abc123.png", "abc123.png"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := store.KeyFromURL(tc.rawURL) if got != tc.expected { t.Errorf("KeyFromURL(%q) = %q, want %q", tc.rawURL, got, tc.expected) } }) } } func TestLocalStorage_DeleteKeys(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } ctx := context.Background() data := []byte("hello world") keys := []string{"file1.txt", "file2.txt", "file3.txt"} for _, key := range keys { _, err := store.Upload(ctx, key, data, "text/plain", key) if err != nil { t.Fatalf("Upload %s failed: %v", key, err) } } store.DeleteKeys(ctx, keys) for _, key := range keys { filePath := filepath.Join(tmpDir, key) if _, err := os.ReadFile(filePath); !os.IsNotExist(err) { t.Errorf("file %s should be deleted", key) } } } func TestLocalStorage_KeyFromURL_Empty(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } if got := store.KeyFromURL(""); got != "" { t.Errorf("KeyFromURL(\"\") = %q, want empty string", got) } } // TestLocalStorage_ServeFile_ContentDispositionFromSidecar verifies the fix // for issue #2442: downloads served from /uploads/* must carry the original // uploaded filename in Content-Disposition, mirroring the S3 Upload path's // existing ContentDisposition behavior. Without this, browsers fall back to // the storage-key basename (UUID + ext) for the download filename. func TestLocalStorage_ServeFile_ContentDispositionFromSidecar(t *testing.T) { tmpDir := t.TempDir() t.Setenv("LOCAL_UPLOAD_DIR", tmpDir) store := NewLocalStorageFromEnv() if store == nil { t.Fatal("NewLocalStorageFromEnv returned nil") } cases := []struct { name string key string contentType string filename string wantHeader string }{ { name: "attachment for non-inline type", key: "workspaces/ws-1/abc.xlsx", contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", filename: "Bwave JE_V1.xlsx", wantHeader: `attachment; filename="Bwave JE_V1.xlsx"`, }, { name: "inline for image", key: "workspaces/ws-1/def.png", contentType: "image/png", filename: "screenshot 2026-05-11.png", wantHeader: `inline; filename="screenshot 2026-05-11.png"`, }, { name: "filename with header-injection characters is sanitized", key: "workspaces/ws-1/ghi.txt", contentType: "text/plain", filename: "weird\";name.txt", wantHeader: `attachment; filename="weird__name.txt"`, }, { // SVG can carry