krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
main: HutchTests/ReadmeViewTests.swift · raw
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 OrgRenderingTests {
226
227 @Test
228 func orgDeepHeading() {
229 let html = orgToHTML("**** Level 4 Heading")
230
231 #expect(html.contains("<h4>"))
232 }
233
234 @Test
235 func orgCommentLinesIgnored() {
236 let html = orgToHTML("# This is a comment\nNormal text")
237
238 #expect(!html.contains("This is a comment"))
239 #expect(html.contains("Normal text"))
240 }
241
242 @Test
243 func orgStrikethrough() {
244 let html = orgToHTML("+deleted text+")
245
246 #expect(html.contains("<del>"))
247 }
248
249 @Test
250 func orgExampleBlock() {
251 let html = orgToHTML("#+begin_example\nhello world\n#+end_example")
252
253 #expect(html.contains("<pre><code>"))
254 #expect(html.contains("hello world"))
255 }
256
257 @Test
258 func orgTitleKeyword() {
259 let html = orgToHTML("#+TITLE: My Document\nBody text")
260
261 #expect(html.contains("org-title"))
262 #expect(html.contains("My Document"))
263 }
264
265 @Test
266 func orgItalicDoesNotMatchURLPaths() {
267 let html = orgToHTML("[[https://example.com/path/to/file]]")
268
269 #expect(!html.contains("<em>"))
270 }
271
272 @Test
273 func orgWrappedBulletNormalizesLines() {
274 let html = orgToHTML("- First line\n continues here")
275
276 #expect(html.contains("<li>First line continues here</li>"))
277 }
278
279 @Test
280 func orgHeaderKeywordsIgnored() {
281 let html = orgToHTML("""
282 #+OPTIONS: toc:nil
283 #+PROPERTY: header-args :results output
284 Body text
285 """)
286
287 #expect(!html.contains("#+OPTIONS"))
288 #expect(!html.contains("#+PROPERTY"))
289 #expect(html.contains("<p>Body text</p>"))
290 }
291
292 @Test
293 func orgVerseBlock() {
294 let html = orgToHTML("""
295 #+begin_verse
296 There is a line.
297 And an indented line.
298 #+end_verse
299 """)
300
301 #expect(html.contains(#"<blockquote class="org-verse">"#))
302 #expect(html.contains("There is a line."))
303 #expect(html.contains("And an indented line."))
304 #expect(!html.contains("#+begin_verse"))
305 }
306
307 @Test
308 func orgNamedBlockRendersCaption() {
309 let html = orgToHTML("""
310 #+CAPTION: Build output
311 #+NAME: build-log
312 #+begin_example
313 hello world
314 #+end_example
315 """)
316
317 #expect(html.contains(#"<figure class="org-block" id="build-log">"#))
318 #expect(html.contains("<figcaption>Build output</figcaption>"))
319 #expect(!html.contains("#+CAPTION"))
320 #expect(!html.contains("#+NAME"))
321 }
322
323 @Test
324 func orgLinkedImageRenders() {
325 let html = orgToHTML("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]")
326
327 #expect(html.contains(#"<a href="https://example.com">"#))
328 #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#))
329 }
330
331 @Test
332 func orgLinkedRelativeImageRenders() {
333 let html = orgToHTML(
334 "[[https://example.com/docs][[./images/badge.svg]]]",
335 imageURLResolver: { source in
336 source == "./images/badge.svg" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" : nil
337 }
338 )
339
340 #expect(html.contains(#"<a href="https://example.com/docs">"#))
341 #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#))
342 }
343
344 @Test
345 func orgNestedBulletListRendersNestedMarkup() {
346 let html = orgToHTML("""
347 * Lists
348 ** Unordered
349 - First bullet
350 - Second bullet
351 - Third bullet with /italic/ and *bold*
352 - Bullet with wrapped
353 continuation line
354 - Bullet with nested list
355 - Nested child one
356 - Nested child two
357 - Bullet with inline code ~let x = 1~
358 """)
359
360 #expect(html.contains("<ul>"))
361 #expect(html.contains("<li>Bullet with nested list\n<ul>"))
362 #expect(html.contains("<li>Nested child one</li>"))
363 #expect(html.contains("<li>Nested child two</li>"))
364 #expect(html.contains("<li>Bullet with wrapped continuation line</li>"))
365 }
366
367 @Test
368 func orgTableAlignmentRendersStyles() {
369 let html = orgToHTML("""
370 | Left | Center | Right |
371 |:-----+:-----:+------:|
372 | a | b | c |
373 | 1 | 2 | 3 |
374 """)
375
376 #expect(html.contains("text-align: left;"))
377 #expect(html.contains("text-align: center;"))
378 #expect(html.contains("text-align: right;"))
379 }
380}
381
382@MainActor
383struct RepositoryAssetURLTests {
384
385 @Test
386 func repositoryAssetURLPercentEncodesImagePaths() {
387 let url = resolveRepositoryAssetURL(
388 "images/My Logo.png",
389 owner: "~ccleberg",
390 repositoryName: "Hutch",
391 readmePath: "README.md"
392 )
393
394 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
395 }
396}
397
398@MainActor
399struct RepositoryLinkURLTests {
400
401 @Test
402 func repositoryLinkURLResolvesRelativePath() {
403 let url = resolveRepositoryLinkURL(
404 "LICENSE",
405 owner: "~ccleberg",
406 repositoryName: "Hutch",
407 readmePath: "README.md"
408 )
409
410 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
411 }
412
413 @Test
414 func repositoryLinkURLPassesThroughAbsoluteURL() {
415 let url = resolveRepositoryLinkURL(
416 "https://example.com/page",
417 owner: "~ccleberg",
418 repositoryName: "Hutch",
419 readmePath: "README.md"
420 )
421
422 #expect(url == "https://example.com/page")
423 }
424
425 @Test
426 func repositoryLinkURLPassesThroughFragment() {
427 let url = resolveRepositoryLinkURL(
428 "#install",
429 owner: "~ccleberg",
430 repositoryName: "Hutch",
431 readmePath: "README.md"
432 )
433
434 #expect(url == "#install")
435 }
436
437 @Test
438 func repositoryLinkURLPassesThroughMailto() {
439 let url = resolveRepositoryLinkURL(
440 "mailto:hello@example.com",
441 owner: "~ccleberg",
442 repositoryName: "Hutch",
443 readmePath: "README.md"
444 )
445
446 #expect(url == "mailto:hello@example.com")
447 }
448
449 @Test
450 func repositoryLinkURLResolvesSubdirectoryRelativePath() {
451 let url = resolveRepositoryLinkURL(
452 "docs/SECURITY.md",
453 owner: "~ccleberg",
454 repositoryName: "Hutch",
455 readmePath: "README.md"
456 )
457
458 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
459 }
460}