mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* 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>
129 lines
4.1 KiB
TypeScript
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>{``}</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>{""}</MarkdownBase>);
|
|
|
|
expect(screen.getByAltText("cat")).toHaveAttribute(
|
|
"src",
|
|
"https://cdn.example.com/cat.png",
|
|
);
|
|
});
|
|
|
|
it("strips non-image data URIs (data:text/html)", () => {
|
|
render(
|
|
<MarkdownBase>{"</script>)"}</MarkdownBase>,
|
|
);
|
|
|
|
const img = screen.getByAltText("x");
|
|
expect(img.getAttribute("src") ?? "").toBe("");
|
|
});
|
|
});
|