mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* feat(storage): add GetReader to Storage interface Adds a streaming read method to the Storage abstraction so callers can pull object bytes without forcing a full in-memory load. S3Storage wraps GetObject; LocalStorage opens the file with path-traversal and sidecar guards. Tests cover happy path, traversal rejection, sidecar rejection, and missing key. Used in the next commit by the attachment-preview proxy endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): add attachment preview proxy endpoint GET /api/attachments/{id}/content streams the raw bytes of a text-previewable attachment back to the client. Exists to (a) bypass CloudFront CORS, which is not configured on the CDN, and (b) bypass Content-Disposition: attachment which Chromium honors for iframe document loads. Media types (image/video/audio/pdf) intentionally do NOT go through this endpoint — clients render them directly from the signed CloudFront download_url, which is already served with Content-Disposition: inline. Hard cap: 2 MB. Larger files return 413. Anything outside the text whitelist returns 415. The whitelist (isTextPreviewable) mirrors the client-side dispatcher; the cross-reference comment in file.go flags the manual sync until a JSON SSOT generator lands. Response always uses Content-Type: text/plain; charset=utf-8 so a hostile HTML payload can't be re-interpreted as a document. The original MIME ships via X-Original-Content-Type for client dispatch. Cache-Control: no-store so revoked attachment access takes effect immediately on the next request. Tests cover happy path (md), extension fallback when content_type is generic, 415 (pdf), 413 (>2MB), foreign workspace (404 isolation), and the isTextPreviewable table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core/api): add getAttachmentTextContent + preview error types Adds an ApiClient method that fetches the text body of an attachment via the new /api/attachments/{id}/content proxy. Two typed errors — PreviewTooLargeError (413) and PreviewUnsupportedError (415) — let the preview modal render specific fallbacks instead of a generic failure. Refactors the private fetch() into a shared fetchRaw() helper so the new method inherits the standard infra: auth headers, 401 → handleUnauthorized recovery, X-Request-ID, error logging, and the ApiError contract. The previous draft bypassed all of these by calling window.fetch directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(views/editor): add AttachmentPreviewModal + Eye entry points In-app preview for non-image attachments. An Eye icon now sits next to the existing Download button on file cards / readonly file cards / the standalone AttachmentList. Clicking it opens a full-screen modal that dispatches by content_type: pdf: <iframe src={download_url}> — Chromium PDFium video/*: <video controls src={download_url}> — native controls audio/*: <audio controls src={download_url}> — native controls md: <ReadonlyContent> — full markdown pipeline html: <iframe srcdoc sandbox=""> — fully restricted text: <code class="hljs"> — lowlight highlight Media types render directly from the signed CloudFront download_url (server marks them inline-disposition). Text types fetch through the new /api/attachments/{id}/content proxy via TanStack Query, wrapped in useAttachmentPreview() so each entry point owns its own modal state without depending on a global Provider mount. Modal sizing: max-w-6xl × min(90vh, 100vh - 2rem) — slightly larger than create-issue's max-w-4xl since PDF / video need room, but capped to viewport on small screens. Sub-renderers use h-full to follow the fixed modal height instead of viewport-relative units. Images are intentionally NOT touched — the existing ImageLightbox (extensions/image-view.tsx) already handles them correctly. The new modal would be churn without user-visible benefit. Adds i18n keys under attachment.* (en + zh-Hans) and registers Preview/Download/Upload in the conventions glossary so future translations stay consistent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(desktop): enable Chromium PDF viewer for attachment preview Adds webPreferences.plugins: true to the main BrowserWindow so the bundled Chromium PDFium plugin activates inside iframes — required for the attachment preview modal's PDF dispatch. Default is false in Electron; without it <iframe src=*.pdf> renders blank. Security trade-off, accepted intentionally and documented inline: 1. This window already runs with webSecurity: false + sandbox: false, so plugins: true does NOT meaningfully widen the renderer's attack surface beyond what is already accepted. 2. The only PDFs that reach an iframe here are signed CloudFront URLs we ourselves issued; user-supplied URLs are routed through setWindowOpenHandler → openExternalSafely and cannot land in this renderer. 3. Chromium's PDFium plugin is itself sandboxed and only handles application/pdf — no Flash/Java/other historical plugin surfaces. If we ever tighten webSecurity / sandbox, the follow-up is to host the PDF viewer in a dedicated BrowserView with plugins scoped to that view, keeping the main renderer plugin-free. Old desktop builds ship without the preview modal, so the Eye button never appears and PDF preview is gated by the same release — zero regression risk for users on stale clients. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
506 lines
18 KiB
Go
506 lines
18 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
const prefix = "https://cdn.example.com/"
|
|
if strings.HasPrefix(rawURL, prefix) {
|
|
return strings.TrimPrefix(rawURL, prefix)
|
|
}
|
|
return rawURL
|
|
}
|
|
func (m *mockStorage) CdnDomain() string { return "cdn.example.com" }
|
|
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 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 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 TestGetAttachmentContent_HappyPath_Markdown(t *testing.T) {
|
|
store := &mockStorage{}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = store
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
body := []byte("# heading\n\nbody text\n")
|
|
id := seedPreviewAttachment(t, store, "preview-md-key.md", "preview.md", "text/markdown", body)
|
|
|
|
req, w := newPreviewRequest(t, id, testWorkspaceID)
|
|
testHandler.GetAttachmentContent(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != string(body) {
|
|
t.Errorf("body = %q, want %q", got, body)
|
|
}
|
|
if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want text/plain; charset=utf-8", got)
|
|
}
|
|
if got := w.Header().Get("X-Original-Content-Type"); got != "text/markdown" {
|
|
t.Errorf("X-Original-Content-Type = %q, want text/markdown", got)
|
|
}
|
|
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
}
|
|
|
|
// Even when http.DetectContentType returned "text/plain" instead of "text/markdown"
|
|
// (a known sniffer quirk), the extension whitelist still grants access.
|
|
func TestGetAttachmentContent_AcceptsByExtensionWhenContentTypeIsGeneric(t *testing.T) {
|
|
store := &mockStorage{}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = store
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
body := []byte("package main\n")
|
|
id := seedPreviewAttachment(t, store, "main-go-key.go", "main.go", "application/octet-stream", body)
|
|
|
|
req, w := newPreviewRequest(t, id, testWorkspaceID)
|
|
testHandler.GetAttachmentContent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetAttachmentContent_Unsupported_PDF(t *testing.T) {
|
|
store := &mockStorage{}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = store
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
id := seedPreviewAttachment(t, store, "pdf-key.pdf", "manual.pdf", "application/pdf", []byte("%PDF-1.4\n"))
|
|
|
|
req, w := newPreviewRequest(t, id, testWorkspaceID)
|
|
testHandler.GetAttachmentContent(w, req)
|
|
if w.Code != http.StatusUnsupportedMediaType {
|
|
t.Fatalf("status = %d, want 415; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetAttachmentContent_TooLarge(t *testing.T) {
|
|
store := &mockStorage{}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = store
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
// One byte over the limit. Allocate ASCII so io.ReadAll has work to do.
|
|
big := bytes.Repeat([]byte("a"), maxPreviewTextSize+1)
|
|
id := seedPreviewAttachment(t, store, "huge-key.txt", "huge.txt", "text/plain", big)
|
|
|
|
req, w := newPreviewRequest(t, id, testWorkspaceID)
|
|
testHandler.GetAttachmentContent(w, req)
|
|
if w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want 413; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetAttachmentContent_ForeignWorkspace(t *testing.T) {
|
|
store := &mockStorage{}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = store
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
id := seedPreviewAttachment(t, store, "ws-mismatch.md", "note.md", "text/markdown", []byte("# secret\n"))
|
|
|
|
// Same attachment id, but request comes in scoped to a different workspace.
|
|
foreign := "00000000-0000-0000-0000-000000000099"
|
|
req, w := newPreviewRequest(t, id, foreign)
|
|
testHandler.GetAttachmentContent(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetAttachmentContent_NotFound(t *testing.T) {
|
|
store := &mockStorage{}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = store
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
req, w := newPreviewRequest(t, "00000000-0000-0000-0000-000000000abc", testWorkspaceID)
|
|
testHandler.GetAttachmentContent(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// isTextPreviewable is the whitelist linkpin between the proxy and the
|
|
// client-side dispatcher. Regress against the most common content types so
|
|
// drifting one of the lists alone fails loud.
|
|
func TestIsTextPreviewable(t *testing.T) {
|
|
t.Helper()
|
|
cases := []struct {
|
|
name string
|
|
contentType string
|
|
filename string
|
|
want bool
|
|
}{
|
|
{"markdown by ext", "application/octet-stream", "README.md", true},
|
|
{"markdown by mime", "text/markdown", "README", true},
|
|
{"plain text", "text/plain", "log.txt", true},
|
|
{"json by mime", "application/json", "data.json", true},
|
|
{"yaml by ext", "application/octet-stream", "config.yml", true},
|
|
{"go source", "text/plain", "main.go", true},
|
|
{"typescript", "application/octet-stream", "index.ts", true},
|
|
{"html", "text/html", "page.html", true},
|
|
{"dockerfile no ext", "application/octet-stream", "Dockerfile", true},
|
|
|
|
{"pdf rejected", "application/pdf", "doc.pdf", false},
|
|
{"png rejected", "image/png", "shot.png", false},
|
|
{"video rejected", "video/mp4", "clip.mp4", false},
|
|
{"binary fallthrough", "application/octet-stream", "blob.bin", false},
|
|
{"docx rejected", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "report.docx", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isTextPreviewable(tc.contentType, tc.filename); got != tc.want {
|
|
t.Errorf("isTextPreviewable(%q, %q) = %v, want %v", tc.contentType, tc.filename, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|