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("![logo](https://example.com/logo.png)")
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("[![badge](https://example.com/badge.png)](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("&lt;b&gt;"))
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("![badge](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)")
183
184        // `&amp;` 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 "&amp;" in the query string.
187        #expect(html.contains("metric=security_rating"))
188        #expect(!html.contains("&amp;amp;"))
189        #expect(html.contains("<img"))
190    }
191
192    @Test
193    func markdownLinkedImagesWithQueryStringsRenderAllImages() {
194        let html = markdownToHTML("""
195        [![builds.sr.ht status](https://builds.sr.ht/~ccleberg/Hutch.svg)](https://builds.sr.ht/~ccleberg/Hutch?)
196        [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
197        [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=reliability_rating)](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] &lt;FILE&gt;</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}