Files
multica/packages/views/common/markdown.test.tsx
Bohan Jiang c3a33fff49 fix(markdown): render inline data-URI images (MUL-3961) (#4832)
* fix(markdown): render inline data-URI images (MUL-3961)

Inline data:image/* URIs (QR codes, charts, base64 screenshots) were
stripped and rendered as broken images. Two gates dropped the src:

- rehype-sanitize's protocols.src only allowed http/https
- react-markdown's defaultUrlTransform blanks any data: URL to ''

Allow data:image/* through both gates, narrowed to image subtypes only
(non-image data URIs stay rejected) and leaving every other src form
unchanged. file-cards.ts data: rejection is intentional and untouched.

Co-authored-by: multica-agent <github@multica.ai>

* fix(markdown): allow data:image/* in ReadonlyContent too (MUL-3961)

Issue comments and other read-only surfaces render through ReadonlyContent,
which keeps its own sanitize schema + urlTransform separate from the base
Markdown component. Both still stripped data: URIs, so an agent inlining an
auth QR code in an issue comment still saw a broken image.

Apply the same image/*-narrowed data: allowance (protocols.src +
attributes.img + urlTransform) and add a regression test covering the
comment / readonly path. file-cards.ts data: rejection stays untouched.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <agent-j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 14:02:05 +08:00

129 lines
4.1 KiB
TypeScript

import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Markdown as MarkdownBase } from "@multica/ui/markdown";
import { Markdown } from "./markdown";
vi.mock("@multica/core/config", () => ({
useConfigStore: (selector: (state: { cdnDomain: string }) => unknown) =>
selector({ cdnDomain: "" }),
}));
vi.mock("../issues/components/issue-mention-card", () => ({
IssueMentionCard: ({ issueId }: { issueId: string }) => (
<span data-testid="issue-mention-card">{issueId}</span>
),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
useRequiredWorkspaceSlug: () => "acme",
useWorkspacePaths: () => ({
...actual.paths.workspace("acme"),
projectDetail: (projectId: string) => `/projects/${projectId}`,
}),
};
});
vi.mock("../navigation", () => ({
AppLink: ({
href,
children,
className,
}: {
href: string;
children: ReactNode;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock("../projects/components/project-chip", () => ({
ProjectChip: ({ projectId }: { projectId: string }) => (
<span data-testid="project-chip">{projectId}</span>
),
}));
const ligatureClasses = [
"[font-variant-ligatures:none]",
"[font-feature-settings:'liga'_0]",
];
describe("Markdown", () => {
it("disables ligatures inside raw code tags", () => {
render(<Markdown>{"<code>uv run --extra dev pytest -q</code>"}</Markdown>);
expect(screen.getByText("uv run --extra dev pytest -q")).toHaveClass(...ligatureClasses);
});
it("disables ligatures inside fenced code blocks", () => {
render(<Markdown>{"```sh\nuv run --extra dev pytest -q\n```"}</Markdown>);
expect(screen.getByText("uv run --extra dev pytest -q")).toHaveClass(...ligatureClasses);
});
it("disables ligatures in terminal-mode code", () => {
render(<Markdown mode="terminal">{"<code>uv run --extra dev pytest -q</code>"}</Markdown>);
expect(screen.getByText("uv run --extra dev pytest -q")).toHaveClass(...ligatureClasses);
});
it("renders slash skill links as slash command pills", () => {
const { container } = render(
<Markdown>[/deploy](slash://skill/abc-123)</Markdown>,
);
const pill = container.querySelector(".slash-command");
expect(pill).not.toBeNull();
expect(pill?.textContent).toBe("/deploy");
});
it("renders project mention links as project chips", () => {
render(<Markdown>{"[Roadmap](mention://project/project-123)"}</Markdown>);
expect(screen.getByTestId("project-chip")).toHaveTextContent("project-123");
expect(screen.getByRole("link")).toHaveAttribute("href", "/projects/project-123");
});
});
// The base renderer uses a plain <img>; exercising it here (instead of the
// views wrapper, which swaps in <Attachment>) lets us assert the sanitized
// `src` directly. Covers the two gates that used to blank data-URI images:
// rehype-sanitize's protocols.src and react-markdown's urlTransform.
describe("Markdown inline data-URI images", () => {
const PNG_1X1 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
it("preserves the src of an inline data:image/png image", () => {
render(<MarkdownBase>{`![demo](${PNG_1X1})`}</MarkdownBase>);
const img = screen.getByAltText("demo");
expect(img.tagName).toBe("IMG");
expect(img).toHaveAttribute("src", PNG_1X1);
});
it("keeps regular http(s) images working", () => {
render(<MarkdownBase>{"![cat](https://cdn.example.com/cat.png)"}</MarkdownBase>);
expect(screen.getByAltText("cat")).toHaveAttribute(
"src",
"https://cdn.example.com/cat.png",
);
});
it("strips non-image data URIs (data:text/html)", () => {
render(
<MarkdownBase>{"![x](data:text/html,<script>alert(1)</script>)"}</MarkdownBase>,
);
const img = screen.getByAltText("x");
expect(img.getAttribute("src") ?? "").toBe("");
});
});