package handler import ( "bytes" "context" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "testing" ) type mockStorage struct{} func (m *mockStorage) Upload(_ context.Context, key string, _ []byte, _ string, _ string) (string, error) { return fmt.Sprintf("https://cdn.example.com/%s", key), nil } func (m *mockStorage) Delete(_ context.Context, _ string) {} func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {} func (m *mockStorage) KeyFromURL(rawURL string) string { return rawURL } func (m *mockStorage) CdnDomain() string { return "cdn.example.com" } func TestUploadFileForeignWorkspace(t *testing.T) { origStorage := testHandler.Storage testHandler.Storage = &mockStorage{} defer func() { testHandler.Storage = origStorage }() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "test.txt") if err != nil { t.Fatal(err) } part.Write([]byte("hello world")) writer.Close() foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" req := httptest.NewRequest("POST", "/api/upload-file", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("X-User-ID", testUserID) req.Header.Set("X-Workspace-ID", foreignWorkspaceID) w := httptest.NewRecorder() testHandler.UploadFile(w, req) if w.Code != http.StatusForbidden { t.Fatalf("UploadFile with foreign workspace: expected 403, got %d: %s", w.Code, w.Body.String()) } } // TestUploadFileResolvesWorkspaceViaSlugHeader is a regression test for the // v2 workspace URL refactor (#1141). The frontend switched from sending // X-Workspace-ID (UUID) to X-Workspace-Slug. For endpoints that sit outside // the workspace middleware — like /api/upload-file — the handler-side // resolver must accept the slug and translate it to a UUID, otherwise the // handler silently falls through to the "no workspace context" branch and // skips creating the DB attachment record. Files end up in S3 with no row // in the attachment table, invisible to the UI. func TestUploadFileResolvesWorkspaceViaSlugHeader(t *testing.T) { origStorage := testHandler.Storage testHandler.Storage = &mockStorage{} defer func() { testHandler.Storage = origStorage }() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "slug-upload.txt") if err != nil { t.Fatal(err) } part.Write([]byte("hello via slug")) writer.Close() req := httptest.NewRequest("POST", "/api/upload-file", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("X-User-ID", testUserID) // Intentionally NOT setting X-Workspace-ID — post-v2 clients only send slug. req.Header.Set("X-Workspace-Slug", handlerTestWorkspaceSlug) w := httptest.NewRecorder() testHandler.UploadFile(w, req) if w.Code != http.StatusOK { t.Fatalf("UploadFile with slug header: expected 200, got %d: %s", w.Code, w.Body.String()) } // The workspace-aware branch returns the full AttachmentResponse (with // id, workspace_id, uploader, etc.). The no-workspace-context branch // returns only {filename, link}. Distinguish by checking the shape. var resp map[string]any if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v; body: %s", err, w.Body.String()) } if _, ok := resp["id"]; !ok { t.Fatalf("expected attachment response with 'id' field (DB row created); got fallback link-only response: %s", w.Body.String()) } if gotWs, _ := resp["workspace_id"].(string); gotWs != testWorkspaceID { t.Fatalf("attachment workspace_id mismatch: want %s, got %v", testWorkspaceID, resp["workspace_id"]) } // Verify the row actually exists in the database. var count int if err := testPool.QueryRow( context.Background(), `SELECT count(*) FROM attachment WHERE workspace_id = $1 AND filename = $2`, testWorkspaceID, "slug-upload.txt", ).Scan(&count); err != nil { t.Fatalf("query attachment count: %v", err) } if count != 1 { t.Fatalf("attachment row count: want 1, got %d", count) } // Clean up so reruns don't accumulate rows. if _, err := testPool.Exec( context.Background(), `DELETE FROM attachment WHERE workspace_id = $1 AND filename = $2`, testWorkspaceID, "slug-upload.txt", ); err != nil { t.Fatalf("cleanup attachment: %v", err) } } // TestUploadFileResolvesWorkspaceViaIDHeaderStill confirms the legacy path // (CLI / daemon clients sending X-Workspace-ID as a UUID) still works after // the refactor. Prevents a regression in the CLI/daemon compat branch. func TestUploadFileResolvesWorkspaceViaIDHeaderStill(t *testing.T) { origStorage := testHandler.Storage testHandler.Storage = &mockStorage{} defer func() { testHandler.Storage = origStorage }() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "uuid-upload.txt") if err != nil { t.Fatal(err) } part.Write([]byte("hello via uuid")) writer.Close() req := httptest.NewRequest("POST", "/api/upload-file", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("X-User-ID", testUserID) req.Header.Set("X-Workspace-ID", testWorkspaceID) w := httptest.NewRecorder() testHandler.UploadFile(w, req) if w.Code != http.StatusOK { t.Fatalf("UploadFile with UUID header: expected 200, got %d: %s", w.Code, w.Body.String()) } // Clean up. if _, err := testPool.Exec( context.Background(), `DELETE FROM attachment WHERE workspace_id = $1 AND filename = $2`, testWorkspaceID, "uuid-upload.txt", ); err != nil { t.Fatalf("cleanup attachment: %v", err) } }