Skip to content
File

Blob: test/markdown-content.test.ts

typescript173 lines
1import { describe, expect, it } from "vitest";
2 
3import { renderMarkdownToHtml } from "@/client/components/MarkdownContent";
4 
5const markdownContext = {
6 owner: "alice",
7 repo: "demo",
8 ref: "main",
9 baseDir: "docs/guide",
10};
11 
12describe("renderMarkdownToHtml", () => {
13 it("drops dangerous text-bearing elements and their children", () => {
14 const html = renderMarkdownToHtml(
15 [
16 "<script>alert(1)</script>",
17 "<style>.bad { color: red; }</style>",
18 "<textarea>secret</textarea>",
19 "<option>choice</option>",
20 "<p>visible</p>",
21 ].join(""),
22 markdownContext
23 );
24 
25 expect(html).toContain("<p>visible</p>");
26 expect(html).not.toContain("alert");
27 expect(html).not.toContain(".bad");
28 expect(html).not.toContain("secret");
29 expect(html).not.toContain("choice");
30 expect(html).not.toContain("<script");
31 expect(html).not.toContain("<style");
32 expect(html).not.toContain("<textarea");
33 expect(html).not.toContain("<option");
34 });
35 
36 it("unwraps unknown tags while preserving sanitized children", () => {
37 const html = renderMarkdownToHtml(
38 "<unknown>before <strong>kept</strong><nested> after</nested></unknown>",
39 markdownContext
40 );
41 
42 expect(html).toContain("before <strong>kept</strong> after");
43 expect(html).not.toContain("<unknown");
44 expect(html).not.toContain("<nested");
45 });
46 
47 it("strips event attributes from otherwise allowed elements", () => {
48 const html = renderMarkdownToHtml(
49 '<img src="/logo.png" alt="Logo" onerror="alert(1)" loading="lazy">',
50 markdownContext
51 );
52 
53 expect(html).toContain('<img src="/logo.png" alt="Logo" loading="lazy">');
54 expect(html).not.toContain("onerror");
55 expect(html).not.toContain("alert");
56 });
57 
58 it("removes unsafe URL attributes", () => {
59 const html = renderMarkdownToHtml(
60 [
61 '<a href="javascript:alert(1)">plain</a>',
62 '<a href="java&#x73;cript&#58;alert(1)">encoded</a>',
63 '<img src="data:text/html,alert(1)" alt="inline">',
64 ].join(""),
65 markdownContext
66 );
67 
68 expect(html).toContain("<a>plain</a>");
69 expect(html).toContain("<a>encoded</a>");
70 expect(html).toContain('<img alt="inline">');
71 expect(html).not.toContain("href=");
72 expect(html).not.toContain("src=");
73 expect(html).not.toContain("javascript");
74 expect(html).not.toContain("data:text/html");
75 });
76 
77 it("preserves allowed URL shapes and normalizes attribute escaping", () => {
78 const html = renderMarkdownToHtml(
79 [
80 '<a href="http://example.com/a?x=1&y=2">http</a>',
81 '<a href="https://example.com/a?x=1&amp;y=2">encoded</a>',
82 '<a href="ftp://example.com/file">ftp</a>',
83 '<a href="mailto:dev@example.com">mailto</a>',
84 '<a href="tel:+15551234567">tel</a>',
85 '<a href="//cdn.example.com/file">protocol</a>',
86 '<a href="/root/path">root</a>',
87 '<a href="../relative/path">relative</a>',
88 '<a href="#section">fragment</a>',
89 ].join(""),
90 markdownContext
91 );
92 
93 expect(html).toContain('href="http://example.com/a?x=1&amp;y=2"');
94 expect(html).toContain('href="https://example.com/a?x=1&amp;y=2"');
95 expect(html).not.toContain("&amp;amp;");
96 expect(html).toContain('href="ftp://example.com/file"');
97 expect(html).toContain('href="mailto:dev@example.com"');
98 expect(html).toContain('href="tel:+15551234567"');
99 expect(html).toContain('href="//cdn.example.com/file"');
100 expect(html).toContain('href="/root/path"');
101 expect(html).toContain('href="../relative/path"');
102 expect(html).toContain('href="#section"');
103 });
104 
105 it("filters classes to the current highlighting allowlist", () => {
106 const html = renderMarkdownToHtml(
107 [
108 '<pre class="noise markdown-code-block">',
109 '<code class="hljs language-ts extra">',
110 '<span class="hljs-keyword extra">const</span>',
111 "</code>",
112 "</pre>",
113 ].join(""),
114 markdownContext
115 );
116 
117 expect(html).toContain(
118 '<pre class="markdown-code-block"><code class="hljs language-ts"><span class="hljs-keyword">const</span></code></pre>'
119 );
120 expect(html).not.toContain("noise");
121 expect(html).not.toContain("extra");
122 });
123 
124 it("rewrites relative Markdown links and images through repo routes", () => {
125 const html = renderMarkdownToHtml(
126 "[Doc](../README.md#intro)\n\n![Logo](assets/logo.png)",
127 markdownContext
128 );
129 
130 expect(html).toContain('href="/alice/demo/blob?ref=main&amp;path=docs%2FREADME.md#intro"');
131 expect(html).toContain(
132 'src="/alice/demo/rawpath?ref=main&amp;path=docs%2Fguide%2Fassets%2Flogo.png&amp;name=logo.png"'
133 );
134 expect(html).toContain('alt="Logo"');
135 expect(html).toContain('loading="lazy"');
136 });
137 
138 it("keeps Markdown after raw details blocks outside the disclosure", () => {
139 const html = renderMarkdownToHtml(
140 [
141 "<details>",
142 "<summary>Rationale</summary>",
143 "Text before the list.",
144 "",
145 "1. first",
146 "2. second",
147 "</details>",
148 "",
149 "## After",
150 "",
151 "Visible outside.",
152 ].join("\n"),
153 markdownContext
154 );
155 const detailsCloseIndex = html.indexOf("</details>");
156 const afterHeadingIndex = html.indexOf("<h2>After</h2>");
157 
158 expect(html).toContain("<ol>");
159 expect(detailsCloseIndex).toBeGreaterThan(-1);
160 expect(afterHeadingIndex).toBeGreaterThan(detailsCloseIndex);
161 expect(html).toContain("<p>Visible outside.</p>");
162 });
163 
164 it("preserves highlighted code block classes", () => {
165 const html = renderMarkdownToHtml("```js\nconst answer = 1;\n```", markdownContext);
166 
167 expect(html).toContain('<pre class="markdown-code-block">');
168 expect(html).toContain('<code class="hljs language-js">');
169 expect(html).toContain('class="hljs-keyword"');
170 expect(html).toContain("const");
171 });
172});