HutchTests/ReadmeViewTests.swift
302 lines · 9394 bytes
1import Foundation
2import Testing
3@testable import Hutch
4
5@MainActor
6struct ReadmeViewTests {
7
8 @Test
9 func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() {
10 #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil)
11 #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil)
12 #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil)
13 }
14
15 @Test
16 func processInlineDropsUnsafeMarkdownLinks() {
17 let rendered = processInline("[click me](javascript:alert)")
18
19 #expect(rendered == "click me")
20 #expect(!rendered.contains("href="))
21 #expect(!rendered.contains("javascript:"))
22 }
23
24 @Test
25 func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() {
26 #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1")
27 #expect(sanitizedReadmeLinkURLString("mailto:test@example.com") == "mailto:test@example.com")
28 #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
29 }
30}
31
32@MainActor
33struct MarkdownRenderingTests {
34
35 @Test
36 func markdownOrderedList() {
37 let html = markdownToHTML("1. First\n2. Second")
38
39 #expect(html.contains("<ol>"))
40 #expect(html.contains("<li>"))
41 }
42
43 @Test
44 func markdownBlockquote() {
45 let html = markdownToHTML("> This is a quote")
46
47 #expect(html.contains("<blockquote>"))
48 }
49
50 @Test
51 func markdownTable() {
52 let input = "| A | B |\n|---|---|\n| 1 | 2 |"
53 let html = markdownToHTML(input)
54
55 #expect(html.contains("<table>"))
56 #expect(html.contains("<th>"))
57 }
58
59 @Test
60 func markdownTableAlignment() {
61 let input = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
62 let html = markdownToHTML(input)
63
64 #expect(html.contains("text-align: left;"))
65 #expect(html.contains("text-align: center;"))
66 #expect(html.contains("text-align: right;"))
67 }
68
69 @Test
70 func markdownStrikethrough() {
71 let html = markdownToHTML("~~deleted~~")
72
73 #expect(html.contains("<del>"))
74 }
75
76 @Test
77 func markdownDeepHeadings() {
78 let html = markdownToHTML("#### Level 4")
79
80 #expect(html.contains("<h4>"))
81 }
82
83 @Test
84 func markdownHardWrapNormalization() {
85 let html = markdownToHTML("line one\nline two")
86
87 #expect(!html.contains("line one\nline two"))
88 #expect(html.contains("line one"))
89 #expect(html.contains("line two"))
90 }
91
92 @Test
93 func markdownSoftBreakIsSpace() {
94 let html = markdownToHTML("word one\nword two")
95
96 #expect(html.contains("word one word two") || (html.contains("word one") && html.contains("word two")))
97 #expect(!html.contains("<br>"))
98 }
99
100 @Test
101 func markdownUnsafeLinkDropped() {
102 let html = markdownToHTML("[click](javascript:alert(1))")
103
104 #expect(!html.contains("href="))
105 #expect(!html.contains("javascript:"))
106 }
107
108 @Test
109 func markdownRelativeLinkWithoutResolverDropped() {
110 let html = markdownToHTML("[LICENSE](LICENSE)")
111
112 #expect(!html.contains("href="))
113 #expect(html.contains("LICENSE"))
114 }
115
116 @Test
117 func markdownRelativeLinkWithResolverRendersAnchor() {
118 let html = markdownToHTML(
119 "[LICENSE](LICENSE)",
120 linkURLResolver: { source in
121 source == "LICENSE" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE" : nil
122 }
123 )
124
125 #expect(html.contains(#"href="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE""#))
126 #expect(html.contains(">LICENSE</a>"))
127 }
128
129 @Test
130 func markdownFragmentLinkWithResolverPreservesFragment() {
131 let html = markdownToHTML(
132 "[section](#install)",
133 linkURLResolver: { source in
134 resolveRepositoryLinkURL(
135 source,
136 owner: "~ccleberg",
137 repositoryName: "Hutch",
138 readmePath: "README.md"
139 )
140 }
141 )
142
143 #expect(html.contains("href=\"#install\""))
144 }
145
146 @Test
147 func markdownImageRenders() {
148 let html = markdownToHTML("")
149
150 #expect(html.contains("<img src=\"https://example.com/logo.png\" alt=\"logo\">"))
151 #expect(!html.contains(#"\"#))
152 }
153
154 @Test
155 func markdownLinkedImageRendersAnchor() {
156 let html = markdownToHTML("[](https://example.com/build)")
157
158 #expect(html.contains("<a href=\"https://example.com/build\">"))
159 #expect(html.contains("<img src=\"https://example.com/badge.png\" alt=\"badge\">"))
160 #expect(!html.contains(#"\"#))
161 }
162
163 @Test
164 func markdownInlineCodeEscaping() {
165 let html = processInline("`<b>`")
166
167 #expect(html.contains("<code>"))
168 #expect(html.contains("<b>"))
169 #expect(!html.contains("<b>"))
170 }
171
172 @Test
173 func markdownPlainEmailAutolinks() {
174 let html = processInline("Contact root@krz.sh")
175
176 #expect(html.contains(#"href="mailto:root@krz.sh""#))
177 #expect(html.contains(">root@krz.sh</a>"))
178 }
179
180 @Test
181 func markdownImageQueryStringPreservesAmpersands() {
182 let html = processInline("")
183
184 // `&` is the correct encoding for `&` in an attribute value, so the
185 // failure mode to guard against is double-escaping, which would make the
186 // browser request a literal "&" in the query string.
187 #expect(html.contains("metric=security_rating"))
188 #expect(!html.contains("&amp;"))
189 #expect(html.contains("<img"))
190 }
191
192 @Test
193 func markdownLinkedImagesWithQueryStringsRenderAllImages() {
194 let html = markdownToHTML("""
195 [](https://builds.sr.ht/~ccleberg/Hutch?)
196 [](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
197 [](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
198 """)
199
200 #expect(html.contains("Hutch.svg"))
201 #expect(html.contains("metric=security_rating"))
202 #expect(html.contains("metric=reliability_rating"))
203 #expect(!html.contains("amp;amp;"))
204 }
205
206 @Test
207 func markdownAllowsSafeInlineHTML() {
208 let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)
209
210 #expect(html.contains("<strong>Bold</strong>"))
211 #expect(html.contains(#"<a href="https://example.com">Link</a>"#))
212 }
213
214 @Test
215 func markdownProtectedTokensDoNotLeak() {
216 let html = processInline(#"<a href="https://example.com">Back to top</a> `yoshi [ARG] <FILE>`"#)
217
218 #expect(!html.contains("ZZPROTECTED"))
219 #expect(html.contains(#"<a href="https://example.com">Back to top</a>"#))
220 #expect(html.contains("<code>yoshi [ARG] <FILE></code>"))
221 }
222}
223
224@MainActor
225struct RepositoryAssetURLTests {
226
227 @Test
228 func repositoryAssetURLPercentEncodesImagePaths() {
229 let url = resolveRepositoryAssetURL(
230 "images/My Logo.png",
231 owner: "~ccleberg",
232 repositoryName: "Hutch",
233 readmePath: "README.md"
234 )
235
236 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
237 }
238}
239
240@MainActor
241struct RepositoryLinkURLTests {
242
243 @Test
244 func repositoryLinkURLResolvesRelativePath() {
245 let url = resolveRepositoryLinkURL(
246 "LICENSE",
247 owner: "~ccleberg",
248 repositoryName: "Hutch",
249 readmePath: "README.md"
250 )
251
252 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
253 }
254
255 @Test
256 func repositoryLinkURLPassesThroughAbsoluteURL() {
257 let url = resolveRepositoryLinkURL(
258 "https://example.com/page",
259 owner: "~ccleberg",
260 repositoryName: "Hutch",
261 readmePath: "README.md"
262 )
263
264 #expect(url == "https://example.com/page")
265 }
266
267 @Test
268 func repositoryLinkURLPassesThroughFragment() {
269 let url = resolveRepositoryLinkURL(
270 "#install",
271 owner: "~ccleberg",
272 repositoryName: "Hutch",
273 readmePath: "README.md"
274 )
275
276 #expect(url == "#install")
277 }
278
279 @Test
280 func repositoryLinkURLPassesThroughMailto() {
281 let url = resolveRepositoryLinkURL(
282 "mailto:hello@example.com",
283 owner: "~ccleberg",
284 repositoryName: "Hutch",
285 readmePath: "README.md"
286 )
287
288 #expect(url == "mailto:hello@example.com")
289 }
290
291 @Test
292 func repositoryLinkURLResolvesSubdirectoryRelativePath() {
293 let url = resolveRepositoryLinkURL(
294 "docs/SECURITY.md",
295 owner: "~ccleberg",
296 repositoryName: "Hutch",
297 readmePath: "README.md"
298 )
299
300 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
301 }
302}