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
/