mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* fix(storage): scope S3 upload keys by workspace
Upload keys now use `workspaces/{workspace_id}/{uuid}.{ext}` instead of
flat `{uuid}.{ext}`, isolating file storage per workspace. Files uploaded
without workspace context (e.g. avatars) keep the flat key structure.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(storage): scope user uploads under users/{user_id}/ prefix
Non-workspace uploads (avatars, profile images) now use
`users/{user_id}/{uuid}.{ext}` instead of flat `{uuid}.{ext}`,
matching the workspace-scoped pattern from the previous commit.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(storage): fix LocalStorage for nested key paths
- Add MkdirAll before WriteFile to create intermediate directories
for workspace/user-scoped keys
- Fix KeyFromURL to preserve full path after /uploads/ prefix instead
of stripping to just the filename
- Update tests to match new behavior
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(upload): validate ownership before writing to storage
Move Storage.Upload after issue_id/comment_id ownership validation
to prevent orphaned files in S3 when validation fails. Previously,
the file was uploaded first and validation happened after, leaving
files in workspace-scoped S3 prefixes even on rejected requests.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(upload): restore workspace membership check before upload
The membership check was accidentally removed during the upload
reordering refactor. Without it, any authenticated user could upload
files to any workspace by setting the X-Workspace-ID header.
Also restores the comment explaining the 200-on-DB-error behavior.
Refs: MUL-577
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
5.4 KiB
Go
216 lines
5.4 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"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)
|
|
}
|
|
}
|