import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AssistantMarkdown,
safeMarkdownUrl,
} from "@/components/chat/assistant-markdown";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("AssistantMarkdown", () => {
it("allows only absolute HTTP(S) links and conservative document anchors", () => {
const urlNode: Parameters<typeof safeMarkdownUrl>[2] = {
type: "element",
tagName: "a",
properties: {},
children: [],
};
const allowedUrls = [
"https://example.com/docs?q=1",
"http://example.com/docs",
"#Details:part_2.value",
];
const blockedUrls = [
"/admin",
"https://",
"https:example.com",
"https:/example.com",
"https:///example.com",
"https:\\example.com",
"https:\\\\example.com",
"h\nttps://example.com",
"JaVaScRiPt:alert(1)",
" javascript:alert(1)",
"javascript:alert(1)",
"javascript%3Aalert(1)",
"data:text/html,owned",
"file:///etc/passwd",
"blob:https://example.com/id",
"#1invalid",
];
expect(
allowedUrls.map((url) => safeMarkdownUrl(url, "href", urlNode)),
).toEqual(allowedUrls);
expect(
blockedUrls.every(
(url) => safeMarkdownUrl(url, "href", urlNode) == null,
),
).toBe(true);
expect(
safeMarkdownUrl("https://example.com/image.png", "src", urlNode) == null,
).toBe(true);
});
it("renders supported model Markdown as semantic document content", () => {
const { container } = render(
<AssistantMarkdown
content={`## Итог
Обычный **важный** и *курсивный* текст с ~~удалением~~ и \`inline()\`.
- первый пункт
- [x] выполнено
> Проверяем цитату.
| Модель | Статус |
| --- | --- |
| GPT | готова |
---`}
isStreaming={false}
/>,
);
expect(screen.getByRole("heading", { level: 2, name: "Итог" })).toBeVisible();
expect(screen.getByText("важный").tagName).toBe("SPAN");
expect(container.querySelector("em")).toHaveTextContent("курсивный");
expect(container.querySelector("del")).toHaveTextContent("удалением");
expect(container.querySelector('[data-streamdown="inline-code"]')).toHaveTextContent("inline()");
expect(container.querySelector("ul")).toBeInTheDocument();
expect(container.querySelector('input[type="checkbox"]')).toBeDisabled();
expect(container.querySelector("blockquote")).toHaveTextContent("Проверяем цитату.");
expect(screen.getByRole("table")).toHaveTextContent("GPT");
expect(container.querySelector("hr")).toBeInTheDocument();
});
it("renders incomplete Markdown during streaming and converges when completed", () => {
const { rerender } = render(
<AssistantMarkdown content="Ответ с **важным" isStreaming />,
);
expect(screen.getByText(/Ответ с/)).toBeVisible();
rerender(
<AssistantMarkdown content="Ответ с **важным** выводом." isStreaming={false} />,
);
expect(screen.getByText("важным")).toBeVisible();
expect(screen.getByText("важным").closest("span")).toHaveAttribute(
"data-streamdown",
"strong",
);
});
it("renders untrusted model output without active HTML, images, or unsafe navigation", () => {
const { container } = render(
<AssistantMarkdown
content={`<script>window.__owned = true</script>
<img src="https://tracker.invalid/pixel.png" onerror="window.__owned = true">
<b data-probe="raw">visible raw label</b>
<iframe src="https://tracker.invalid/frame.html"></iframe>
<style>body { display: none; }</style>

[safe](https://example.com/docs?q=1)
[anchor](#details)
[relative](/admin)
[javascript](javascript:alert(1))
[data](data:text/html,owned)
[file](file:///etc/passwd)
[blob](blob:https://example.com/id)`}
isStreaming={false}
/>,
);
expect(
container.querySelector("script, img, iframe, object, embed, style"),
).toBeNull();
expect(container.querySelector("b, [data-probe]")).toBeNull();
expect(
["relative", "javascript", "data", "file", "blob"].every((label) =>
container.textContent?.includes(label),
),
).toBe(true);
const renderedLinks = [...container.querySelectorAll("a")];
const safeLink = renderedLinks.find((link) => link.textContent === "safe");
expect(safeLink).toBeDefined();
if (!safeLink) return;
expect(safeLink).toHaveAttribute("href", "https://example.com/docs?q=1");
expect(safeLink).toHaveAttribute("target", "_blank");
expect(safeLink).toHaveAttribute("rel", expect.stringContaining("noopener"));
const anchorLink = renderedLinks.find((link) => link.textContent === "anchor");
expect(anchorLink).toBeDefined();
if (!anchorLink) return;
expect(anchorLink).toHaveAttribute("href", "#details");
expect(anchorLink).not.toHaveAttribute("target");
expect(anchorLink).not.toHaveAttribute("rel");
const approvedHrefs = new Set([
"https://example.com/docs?q=1",
"#details",
]);
const navigableLinks = [...container.querySelectorAll("a[href]")];
expect(navigableLinks.length).toBe(2);
expect(
navigableLinks.some(
(link) => !approvedHrefs.has(link.getAttribute("href") ?? ""),
),
).toBe(false);
});
it("keeps adversarial streaming labels readable without active content or navigation", () => {
const streamingContent = `<b data-probe="stream-raw">visible</b>
[relative](/admin)
[malformed](https:/example.com`;
const staticContent = `${streamingContent})`;
const { container, rerender } = render(
<AssistantMarkdown content={streamingContent} isStreaming />,
);
const expectInertReadableContent = () => {
expect(container.querySelector("b, [data-probe], img, script")).toBeNull();
expect(
["visible", "relative", "malformed"].every((label) =>
container.textContent?.includes(label),
),
).toBe(true);
const links = [...container.querySelectorAll("a[href]")];
expect(
["relative", "malformed"].map((label) =>
links.some((link) => link.textContent === label),
),
).toEqual([false, false]);
};
expectInertReadableContent();
rerender(<AssistantMarkdown content={staticContent} isStreaming={false} />);
expectInertReadableContent();
});
it("rejects case, whitespace, and character-reference URL evasions", () => {
const { container } = render(
<AssistantMarkdown
content={`[case](JaVaScRiPt:alert(1))
[leading](< javascript:alert(1)>)
[reference](javascript:alert(1))`}
isStreaming={false}
/>,
);
const unsafeLabels = new Set(["case", "leading", "reference"]);
const navigableUnsafeLinks = [...container.querySelectorAll("a[href]")].filter(
(link) => unsafeLabels.has(link.textContent ?? ""),
);
const evasionsRemainVisible = [...unsafeLabels].every((label) =>
container.textContent?.includes(label),
);
expect(evasionsRemainVisible).toBe(true);
expect(navigableUnsafeLinks).toHaveLength(0);
});
it("copies only inert fenced code and never offers a download", async () => {
const user = userEvent.setup();
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined);
const { container } = render(
<AssistantMarkdown
content={"```typescript\nconst answer = 42;\n```"}
isStreaming={false}
/>,
);
expect(container.querySelector('[data-streamdown="code-block"]')).toHaveTextContent(
"const answer = 42;",
);
expect(
container.querySelector('[data-streamdown="code-block-header"]'),
).toHaveTextContent("typescript");
expect(
container.querySelector('[data-streamdown="code-block-download-button"]'),
).toBeNull();
const copyButton = container.querySelector<HTMLButtonElement>(
'[data-streamdown="code-block-copy-button"]',
);
expect(copyButton).not.toBeNull();
if (!copyButton) return;
await user.click(copyButton);
expect(writeText).toHaveBeenCalledWith("const answer = 42;\n");
});
it("keeps fenced code intact when clipboard access is rejected", async () => {
const user = userEvent.setup();
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockRejectedValue(new Error("clipboard unavailable"));
const { container } = render(
<AssistantMarkdown
content={"```text\nretained source\n```"}
isStreaming={false}
/>,
);
const codeBlock = container.querySelector('[data-streamdown="code-block"]');
const copyButton = container.querySelector<HTMLButtonElement>(
'[data-streamdown="code-block-copy-button"]',
);
expect(copyButton).not.toBeNull();
if (!copyButton) return;
await user.click(copyButton);
expect(writeText).toHaveBeenCalledWith("retained source\n");
expect(codeBlock?.textContent?.includes("retained source") ?? false).toBe(true);
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(
container.textContent?.includes("clipboard unavailable") ?? false,
).toBe(false);
});
});