package handler import ( "bytes" "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/go-chi/chi/v5" "github.com/multica-ai/multica/server/internal/auth" "github.com/multica-ai/multica/server/internal/storage" db "github.com/multica-ai/multica/server/pkg/db/generated" ) // createHandlerTestChatSession seeds a chat_session row owned by testUserID // targeting the given agent and returns the session UUID. Cleanup runs after // the test. Used by attachment / chat tests that need an existing session. func createHandlerTestChatSession(t *testing.T, agentID string) string { t.Helper() var sessionID string if err := testPool.QueryRow(context.Background(), ` INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status) VALUES ($1, $2, $3, $4, 'active') RETURNING id `, testWorkspaceID, agentID, testUserID, "Handler Test Chat Session").Scan(&sessionID); err != nil { t.Fatalf("failed to create handler test chat session: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, sessionID) }) return sessionID } // mockStorage is a tiny in-memory Storage stand-in. Upload records the bytes // keyed by the storage key so GetReader can round-trip them in tests; KeyFromURL // strips the synthetic CDN host so consumers can pass either the URL or the // raw key. type mockStorage struct { mu sync.Mutex files map[string][]byte presignCalls []string presignDispositions []string } func (m *mockStorage) Upload(_ context.Context, key string, data []byte, _ string, _ string) (string, error) { m.mu.Lock() defer m.mu.Unlock() if m.files == nil { m.files = map[string][]byte{} } m.files[key] = append([]byte(nil), data...) return fmt.Sprintf("https://cdn.example.com/%s", key), nil } func (m *mockStorage) Delete(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() delete(m.files, key) } func (m *mockStorage) DeleteKeys(_ context.Context, _ []string) {} func (m *mockStorage) KeyFromURL(rawURL string) string { for _, prefix := range []string{ "https://cdn.example.com/", "http://rustfs:9000/test-bucket/", "https://s3.example.com/test-bucket/", } { if strings.HasPrefix(rawURL, prefix) { return strings.TrimPrefix(rawURL, prefix) } } return rawURL } func (m *mockStorage) CdnDomain() string { return "cdn.example.com" } // mockStorageNoCdn is a mockStorage variant that returns an empty CdnDomain // to simulate a private S3 / R2 / MinIO deployment where the operator has // NOT configured a public-facing CDN domain. buildMarkdownURL must not // persist `a.Url` for this shape — it would write a private bucket URL // into markdown that no client can load. type mockStorageNoCdn struct{ mockStorage } func (m *mockStorageNoCdn) CdnDomain() string { return "" } func (m *mockStorage) GetReader(_ context.Context, key string) (io.ReadCloser, error) { m.mu.Lock() defer m.mu.Unlock() if data, ok := m.files[key]; ok { return io.NopCloser(bytes.NewReader(data)), nil } return nil, fmt.Errorf("mockStorage GetReader: key not found: %q", key) } func (m *mockStorage) PresignGet(_ context.Context, key string, _ time.Duration) (string, error) { m.mu.Lock() defer m.mu.Unlock() m.presignCalls = append(m.presignCalls, key) return "https://signed.example.com/" + key + "?X-Amz-Signature=mock", nil } func (m *mockStorage) PresignGetWithContentDisposition(_ context.Context, key string, _ time.Duration, contentDisposition string) (string, error) { m.mu.Lock() defer m.mu.Unlock() m.presignCalls = append(m.presignCalls, key) m.presignDispositions = append(m.presignDispositions, contentDisposition) u := url.URL{ Scheme: "https", Host: "signed.example.com", Path: "/" + key, } q := u.Query() q.Set("X-Amz-Signature", "mock") if contentDisposition != "" { q.Set("response-content-disposition", contentDisposition) } u.RawQuery = q.Encode() return u.String(), nil } func (m *mockStorage) put(key string, data []byte) { m.mu.Lock() defer m.mu.Unlock() if m.files == nil { m.files = map[string][]byte{} } m.files[key] = append([]byte(nil), data...) } 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) } } // TestUploadFile_AttachesToChatSession verifies that a multipart upload with // a chat_session_id form field creates an attachment row linked to that chat // session (chat_message_id remains NULL — it is back-filled on send). func TestUploadFile_AttachesToChatSession(t *testing.T) { origStorage := testHandler.Storage testHandler.Storage = &mockStorage{} defer func() { testHandler.Storage = origStorage }() agentID := createHandlerTestAgent(t, "ChatUploadAgent", []byte("[]")) sessionID := createHandlerTestChatSession(t, agentID) var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "chat-upload.png") if err != nil { t.Fatal(err) } // Minimal PNG signature so content-type sniffs as image/png. part.Write([]byte("\x89PNG\r\n\x1a\nrest-of-bytes")) if err := writer.WriteField("chat_session_id", sessionID); err != nil { t.Fatal(err) } 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 chat_session_id: expected 200, got %d: %s", w.Code, w.Body.String()) } var resp AttachmentResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v; body: %s", err, w.Body.String()) } if resp.ChatSessionID == nil || *resp.ChatSessionID != sessionID { t.Fatalf("chat_session_id in response: want %s, got %v", sessionID, resp.ChatSessionID) } if resp.ChatMessageID != nil { t.Fatalf("chat_message_id should be NULL before send, got %v", resp.ChatMessageID) } if resp.IssueID != nil || resp.CommentID != nil { t.Fatalf("issue_id/comment_id should be NULL for chat-only upload: %+v", resp) } if resp.URL == "" { t.Fatal("expected non-empty url") } // Verify the DB row directly. var dbSession, dbMessage *string if err := testPool.QueryRow( context.Background(), `SELECT chat_session_id::text, chat_message_id::text FROM attachment WHERE id = $1`, resp.ID, ).Scan(&dbSession, &dbMessage); err != nil { t.Fatalf("query attachment row: %v", err) } if dbSession == nil || *dbSession != sessionID { t.Fatalf("DB chat_session_id mismatch: want %s, got %v", sessionID, dbSession) } if dbMessage != nil { t.Fatalf("DB chat_message_id should be NULL, got %v", dbMessage) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, resp.ID) }) } // TestUploadFile_RejectsForeignChatSession verifies a chat_session in another // workspace (or owned by another user) is rejected with 403/404, preventing // cross-tenant attachment binding. func TestUploadFile_RejectsForeignChatSession(t *testing.T) { origStorage := testHandler.Storage testHandler.Storage = &mockStorage{} defer func() { testHandler.Storage = origStorage }() var body bytes.Buffer writer := multipart.NewWriter(&body) part, _ := writer.CreateFormFile("file", "evil.txt") part.Write([]byte("payload")) // Random non-existent UUID. writer.WriteField("chat_session_id", "00000000-0000-0000-0000-0000deadbeef") 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.StatusNotFound && w.Code != http.StatusForbidden && w.Code != http.StatusBadRequest { t.Fatalf("UploadFile with unknown chat_session_id: expected 4xx, got %d: %s", w.Code, w.Body.String()) } } // --------------------------------------------------------------------------- // GetAttachmentContent tests (preview proxy) // --------------------------------------------------------------------------- // seedPreviewAttachment inserts an attachment row + writes the bytes into the // active mockStorage. Returns the new attachment id. Caller is responsible for // installing the mockStorage on testHandler before calling. func seedPreviewAttachment(t *testing.T, store *mockStorage, key, filename, contentType string, body []byte) string { t.Helper() // Register the body so GetReader can find it via KeyFromURL → key. url, err := store.Upload(context.Background(), key, body, contentType, filename) if err != nil { t.Fatalf("seed Upload: %v", err) } var id string if err := testPool.QueryRow(context.Background(), ` INSERT INTO attachment (workspace_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) VALUES ($1, 'member', $2, $3, $4, $5, $6) RETURNING id::text `, testWorkspaceID, testUserID, filename, url, contentType, len(body)).Scan(&id); err != nil { t.Fatalf("seed attachment row: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, id) }) return id } func seedAttachmentURL(t *testing.T, rawURL, filename, contentType string, sizeBytes int64) string { t.Helper() var id string if err := testPool.QueryRow(context.Background(), ` INSERT INTO attachment (workspace_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) VALUES ($1, 'member', $2, $3, $4, $5, $6) RETURNING id::text `, testWorkspaceID, testUserID, filename, rawURL, contentType, sizeBytes).Scan(&id); err != nil { t.Fatalf("seed attachment row: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, id) }) return id } func newPreviewRequest(t *testing.T, attachmentID, workspaceID string) (*http.Request, *httptest.ResponseRecorder) { t.Helper() req := httptest.NewRequest("GET", "/api/attachments/"+attachmentID+"/content", nil) req.Header.Set("X-User-ID", testUserID) req.Header.Set("X-Workspace-ID", workspaceID) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", attachmentID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) return req, httptest.NewRecorder() } func newDownloadRequest(t *testing.T, attachmentID, workspaceID string) (*http.Request, *httptest.ResponseRecorder) { t.Helper() req := httptest.NewRequest("GET", "/api/attachments/"+attachmentID+"/download", nil) req.Header.Set("X-User-ID", testUserID) req.Header.Set("X-Workspace-ID", workspaceID) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", attachmentID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) return req, httptest.NewRecorder() } func requireAttachmentPreviewCSP(t *testing.T, header http.Header, extraAncestors ...string) { t.Helper() csp := header.Get("Content-Security-Policy") if csp == "" { t.Fatal("Content-Security-Policy header is missing") } for _, directive := range []string{ "default-src 'none'", "frame-ancestors 'self'", "object-src 'none'", "base-uri 'none'", "form-action 'none'", } { if !strings.Contains(csp, directive) { t.Fatalf("Content-Security-Policy missing %q; got %q", directive, csp) } } for _, ancestor := range extraAncestors { if !strings.Contains(csp, ancestor) { t.Fatalf("Content-Security-Policy missing frame ancestor %q; got %q", ancestor, csp) } } if strings.Contains(csp, "frame-ancestors 'none'") { t.Fatalf("Content-Security-Policy still blocks same-origin previews: %q", csp) } } func TestAttachmentPreviewCSPHeader_AllowsConfiguredFrontendOrigins(t *testing.T) { csp := attachmentPreviewCSPHeader([]string{ "https://app.example.test", " https://App.Example.Test/some/path ", "http://localhost:3000", "*", "javascript:alert(1)", "not a url", }) for _, want := range []string{ "frame-ancestors 'self' https://app.example.test http://localhost:3000", "default-src 'none'", "object-src 'none'", } { if !strings.Contains(csp, want) { t.Fatalf("Content-Security-Policy missing %q; got %q", want, csp) } } for _, reject := range []string{"*", "javascript:", "not a url", "some/path"} { if strings.Contains(csp, reject) { t.Fatalf("Content-Security-Policy includes rejected source %q; got %q", reject, csp) } } if strings.Count(csp, "https://app.example.test") != 1 { t.Fatalf("Content-Security-Policy should dedupe origins; got %q", csp) } } func newDownloadRouter() http.Handler { // Mirrors the production router after MUL-3130: the download // route is registered under Auth-only with no // RequireWorkspaceMember wrapper. The handler self-resolves the // workspace from the attachment row and enforces membership // internally, so a native browser /