mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49: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>
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiClient, ApiError } from "./client";
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("ApiClient", () => {
|
|
it("preserves HTTP status on failed requests", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
|
|
status: 409,
|
|
statusText: "Conflict",
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
try {
|
|
await client.createWorkspace({ name: "Test", slug: "test" });
|
|
throw new Error("expected createWorkspace to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ApiError);
|
|
expect(error).toMatchObject({
|
|
message: "workspace slug already exists",
|
|
status: 409,
|
|
statusText: "Conflict",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("uses the expected HTTP contract for autopilot endpoints", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
|
|
new Response(JSON.stringify({ autopilots: [], runs: [], total: 0 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
|
|
await client.listAutopilots({ status: "active" });
|
|
await client.getAutopilot("ap-1");
|
|
await client.createAutopilot({
|
|
title: "Daily triage",
|
|
assignee_id: "agent-1",
|
|
execution_mode: "create_issue",
|
|
});
|
|
await client.updateAutopilot("ap-1", { status: "paused" });
|
|
await client.deleteAutopilot("ap-1");
|
|
await client.triggerAutopilot("ap-1");
|
|
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
|
|
await client.createAutopilotTrigger("ap-1", {
|
|
kind: "schedule",
|
|
cron_expression: "0 9 * * *",
|
|
timezone: "UTC",
|
|
});
|
|
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
|
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
|
|
|
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
|
url,
|
|
method: init?.method ?? "GET",
|
|
body: init?.body,
|
|
}));
|
|
|
|
expect(calls).toMatchObject([
|
|
{ url: "https://api.example.test/api/autopilots?status=active", method: "GET" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1", method: "GET" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: "Daily triage",
|
|
assignee_id: "agent-1",
|
|
execution_mode: "create_issue",
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1",
|
|
method: "PATCH",
|
|
body: JSON.stringify({ status: "paused" }),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/runs?limit=10&offset=20", method: "GET" },
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers",
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
kind: "schedule",
|
|
cron_expression: "0 9 * * *",
|
|
timezone: "UTC",
|
|
}),
|
|
},
|
|
{
|
|
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1",
|
|
method: "PATCH",
|
|
body: JSON.stringify({ enabled: false }),
|
|
},
|
|
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
|
]);
|
|
});
|
|
|
|
it("emits X-Client-* headers when identity is configured", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test", {
|
|
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
|
|
});
|
|
await client.listWorkspaces();
|
|
|
|
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
|
expect(headers["X-Client-Platform"]).toBe("desktop");
|
|
expect(headers["X-Client-Version"]).toBe("1.2.3");
|
|
expect(headers["X-Client-OS"]).toBe("macos");
|
|
});
|
|
|
|
it("omits X-Client-* headers when identity is not configured", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify([]), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.listWorkspaces();
|
|
|
|
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
|
expect(headers["X-Client-Platform"]).toBeUndefined();
|
|
expect(headers["X-Client-Version"]).toBeUndefined();
|
|
expect(headers["X-Client-OS"]).toBeUndefined();
|
|
});
|
|
|
|
describe("getAttachment", () => {
|
|
it("returns the parsed attachment for a well-formed response", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
id: "att-1",
|
|
workspace_id: "ws-1",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
uploader_type: "member",
|
|
uploader_id: "u-1",
|
|
filename: "report.md",
|
|
url: "https://static.example.test/ws/att-1.md",
|
|
download_url:
|
|
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
|
|
content_type: "text/markdown",
|
|
size_bytes: 123,
|
|
created_at: "2026-05-11T00:00:00Z",
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const att = await client.getAttachment("att-1");
|
|
|
|
expect(att.id).toBe("att-1");
|
|
expect(att.download_url).toContain("Policy=");
|
|
});
|
|
|
|
it("falls back to an empty attachment when the response is missing download_url", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const att = await client.getAttachment("att-1");
|
|
|
|
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
|
|
// safely read `download_url` without crashing — they'll see "" and
|
|
// surface a user-facing error instead of opening `undefined`.
|
|
expect(att.id).toBe("");
|
|
expect(att.download_url).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("getAttachmentTextContent", () => {
|
|
it("returns body text and the original content type from the X-* header", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("# heading\n\nbody\n", {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/plain; charset=utf-8",
|
|
"X-Original-Content-Type": "text/markdown",
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const { text, originalContentType } =
|
|
await client.getAttachmentTextContent("att-1");
|
|
|
|
expect(text).toBe("# heading\n\nbody\n");
|
|
expect(originalContentType).toBe("text/markdown");
|
|
});
|
|
|
|
it("throws PreviewTooLargeError on 413", async () => {
|
|
const { PreviewTooLargeError } = await import("./client");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("", { status: 413, statusText: "Payload Too Large" }),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
|
PreviewTooLargeError,
|
|
);
|
|
});
|
|
|
|
it("throws PreviewUnsupportedError on 415", async () => {
|
|
const { PreviewUnsupportedError } = await import("./client");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(
|
|
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
|
|
),
|
|
);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
|
PreviewUnsupportedError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("chat attachment wiring", () => {
|
|
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
|
await client.uploadFile(file, { chatSessionId: "session-123" });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
expect(url).toBe("https://api.example.test/api/upload-file");
|
|
expect(init?.method).toBe("POST");
|
|
const body = init?.body as FormData;
|
|
expect(body).toBeInstanceOf(FormData);
|
|
expect(body.get("chat_session_id")).toBe("session-123");
|
|
expect(body.get("issue_id")).toBeNull();
|
|
expect(body.get("comment_id")).toBeNull();
|
|
});
|
|
|
|
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
|
|
|
|
const [, init] = fetchMock.mock.calls[0]!;
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
content: "hello",
|
|
attachment_ids: ["att-1", "att-2"],
|
|
});
|
|
});
|
|
|
|
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
|
|
const fetchMock = vi.fn().mockImplementation(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
|
status: 201,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const client = new ApiClient("https://api.example.test");
|
|
await client.sendChatMessage("session-1", "hello");
|
|
await client.sendChatMessage("session-1", "again", []);
|
|
|
|
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
|
|
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
|
|
});
|
|
});
|
|
});
|