krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.10.0: HutchTests/ReadmeViewTests.swift · raw

  1import Foundation
  2import Testing
  3@testable import Hutch
  4
  5struct ReadmeViewTests {
  6
  7    @Test
  8    func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() {
  9        #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil)
 10        #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil)
 11        #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil)
 12    }
 13
 14    @Test
 15    func processInlineDropsUnsafeMarkdownLinks() {
 16        let rendered = processInline("[click me](javascript:alert)")
 17
 18        #expect(rendered == "click me")
 19        #expect(!rendered.contains("href="))
 20        #expect(!rendered.contains("javascript:"))
 21    }
 22
 23    @Test
 24    func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() {
 25        #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1")
 26        #expect(sanitizedReadmeLinkURLString("mailto:test@example.com") == "mailto:test@example.com")
 27        #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
 28    }
 29}
 30
 31struct MarkdownRenderingTests {
 32
 33    @Test
 34    func markdownOrderedList() {
 35        let html = markdownToHTML("1. First\n2. Second")
 36
 37        #expect(html.contains("<ol>"))
 38        #expect(html.contains("<li>"))
 39    }
 40
 41    @Test
 42    func markdownBlockquote() {
 43        let html = markdownToHTML("> This is a quote")
 44
 45        #expect(html.contains("<blockquote>"))
 46    }
 47
 48    @Test
 49    func markdownTable() {
 50        let input = "| A | B |\n|---|---|\n| 1 | 2 |"
 51        let html = markdownToHTML(input)
 52
 53        #expect(html.contains("<table>"))
 54        #expect(html.contains("<th>"))
 55    }
 56
 57    @Test
 58    func markdownTableAlignment() {
 59        let input = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
 60        let html = markdownToHTML(input)
 61
 62        #expect(html.contains("text-align: left;"))
 63        #expect(html.contains("text-align: center;"))
 64        #expect(html.contains("text-align: right;"))
 65    }
 66
 67    @Test
 68    func markdownStrikethrough() {
 69        let html = markdownToHTML("~~deleted~~")
 70
 71        #expect(html.contains("<del>"))
 72    }
 73
 74    @Test
 75    func markdownDeepHeadings() {
 76        let html = markdownToHTML("#### Level 4")
 77
 78        #expect(html.contains("<h4>"))
 79    }
 80
 81    @Test
 82    func markdownHardWrapNormalization() {
 83        let html = markdownToHTML("line one\nline two")
 84
 85        #expect(!html.contains("line one\nline two"))
 86        #expect(html.contains("line one"))
 87        #expect(html.contains("line two"))
 88    }
 89
 90    @Test
 91    func markdownSoftBreakIsSpace() {
 92        let html = markdownToHTML("word one\nword two")
 93
 94        #expect(html.contains("word one word two") || (html.contains("word one") && html.contains("word two")))
 95        #expect(!html.contains("<br>"))
 96    }
 97
 98    @Test
 99    func markdownUnsafeLinkDropped() {
100        let html = markdownToHTML("[click](javascript:alert(1))")
101
102        #expect(!html.contains("href="))
103        #expect(!html.contains("javascript:"))
104    }
105
106    @Test
107    func markdownRelativeLinkWithoutResolverDropped() {
108        let html = markdownToHTML("[LICENSE](LICENSE)")
109
110        #expect(!html.contains("href="))
111        #expect(html.contains("LICENSE"))
112    }
113
114    @Test
115    func markdownRelativeLinkWithResolverRendersAnchor() {
116        let html = markdownToHTML(
117            "[LICENSE](LICENSE)",
118            linkURLResolver: { source in
119                source == "LICENSE" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE" : nil
120            }
121        )
122
123        #expect(html.contains(#"href="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE""#))
124        #expect(html.contains(">LICENSE</a>"))
125    }
126
127    @Test
128    func markdownFragmentLinkWithResolverPreservesFragment() {
129        let html = markdownToHTML(
130            "[section](#install)",
131            linkURLResolver: { source in
132                resolveRepositoryLinkURL(
133                    source,
134                    owner: "~ccleberg",
135                    repositoryName: "Hutch",
136                    readmePath: "README.md"
137                )
138            }
139        )
140
141        #expect(html.contains("href=\"#install\""))
142    }
143
144    @Test
145    func markdownImageRenders() {
146        let html = markdownToHTML("![logo](https://example.com/logo.png)")
147
148        #expect(html.contains("<img src=\"https://example.com/logo.png\" alt=\"logo\">"))
149        #expect(!html.contains(#"\"#))
150    }
151
152    @Test
153    func markdownLinkedImageRendersAnchor() {
154        let html = markdownToHTML("[![badge](https://example.com/badge.png)](https://example.com/build)")
155
156        #expect(html.contains("<a href=\"https://example.com/build\">"))
157        #expect(html.contains("<img src=\"https://example.com/badge.png\" alt=\"badge\">"))
158        #expect(!html.contains(#"\"#))
159    }
160
161    @Test
162    func markdownInlineCodeEscaping() {
163        let html = processInline("`<b>`")
164
165        #expect(html.contains("<code>"))
166        #expect(html.contains("&lt;b&gt;"))
167        #expect(!html.contains("<b>"))
168    }
169
170    @Test
171    func markdownPlainEmailAutolinks() {
172        let html = processInline("Contact root@krz.sh")
173
174        #expect(html.contains(#"href="mailto:root@krz.sh""#))
175        #expect(html.contains(">root@krz.sh</a>"))
176    }
177
178    @Test
179    func markdownImageQueryStringPreservesAmpersands() {
180        let html = processInline("![badge](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)")
181
182        // `&amp;` is the correct encoding for `&` in an attribute value, so the
183        // failure mode to guard against is double-escaping, which would make the
184        // browser request a literal "&amp;" in the query string.
185        #expect(html.contains("metric=security_rating"))
186        #expect(!html.contains("&amp;amp;"))
187        #expect(html.contains("<img"))
188    }
189
190    @Test
191    func markdownLinkedImagesWithQueryStringsRenderAllImages() {
192        let html = markdownToHTML("""
193        [![builds.sr.ht status](https://builds.sr.ht/~ccleberg/Hutch.svg)](https://builds.sr.ht/~ccleberg/Hutch?)
194        [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
195        [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
196        """)
197
198        #expect(html.contains("Hutch.svg"))
199        #expect(html.contains("metric=security_rating"))
200        #expect(html.contains("metric=reliability_rating"))
201        #expect(!html.contains("amp;amp;"))
202    }
203
204    @Test
205    func markdownAllowsSafeInlineHTML() {
206        let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)
207
208        #expect(html.contains("<strong>Bold</strong>"))
209        #expect(html.contains(#"<a href="https://example.com">Link</a>"#))
210    }
211
212    @Test
213    func markdownProtectedTokensDoNotLeak() {
214        let html = processInline(#"<a href="https://example.com">Back to top</a> `yoshi [ARG] <FILE>`"#)
215
216        #expect(!html.contains("ZZPROTECTED"))
217        #expect(html.contains(#"<a href="https://example.com">Back to top</a>"#))
218        #expect(html.contains("<code>yoshi [ARG] &lt;FILE&gt;</code>"))
219    }
220}
221
222struct OrgRenderingTests {
223
224    @Test
225    func orgDeepHeading() {
226        let html = orgToHTML("**** Level 4 Heading")
227
228        #expect(html.contains("<h4>"))
229    }
230
231    @Test
232    func orgCommentLinesIgnored() {
233        let html = orgToHTML("# This is a comment\nNormal text")
234
235        #expect(!html.contains("This is a comment"))
236        #expect(html.contains("Normal text"))
237    }
238
239    @Test
240    func orgStrikethrough() {
241        let html = orgToHTML("+deleted text+")
242
243        #expect(html.contains("<del>"))
244    }
245
246    @Test
247    func orgExampleBlock() {
248        let html = orgToHTML("#+begin_example\nhello world\n#+end_example")
249
250        #expect(html.contains("<pre><code>"))
251        #expect(html.contains("hello world"))
252    }
253
254    @Test
255    func orgTitleKeyword() {
256        let html = orgToHTML("#+TITLE: My Document\nBody text")
257
258        #expect(html.contains("org-title"))
259        #expect(html.contains("My Document"))
260    }
261
262    @Test
263    func orgItalicDoesNotMatchURLPaths() {
264        let html = orgToHTML("[[https://example.com/path/to/file]]")
265
266        #expect(!html.contains("<em>"))
267    }
268
269    @Test
270    func orgWrappedBulletNormalizesLines() {
271        let html = orgToHTML("- First line\n  continues here")
272
273        #expect(html.contains("<li>First line continues here</li>"))
274    }
275
276    @Test
277    func orgHeaderKeywordsIgnored() {
278        let html = orgToHTML("""
279        #+OPTIONS: toc:nil
280        #+PROPERTY: header-args :results output
281        Body text
282        """)
283
284        #expect(!html.contains("#+OPTIONS"))
285        #expect(!html.contains("#+PROPERTY"))
286        #expect(html.contains("<p>Body text</p>"))
287    }
288
289    @Test
290    func orgVerseBlock() {
291        let html = orgToHTML("""
292        #+begin_verse
293        There is a line.
294          And an indented line.
295        #+end_verse
296        """)
297
298        #expect(html.contains(#"<blockquote class="org-verse">"#))
299        #expect(html.contains("There is a line."))
300        #expect(html.contains("And an indented line."))
301        #expect(!html.contains("#+begin_verse"))
302    }
303
304    @Test
305    func orgNamedBlockRendersCaption() {
306        let html = orgToHTML("""
307        #+CAPTION: Build output
308        #+NAME: build-log
309        #+begin_example
310        hello world
311        #+end_example
312        """)
313
314        #expect(html.contains(#"<figure class="org-block" id="build-log">"#))
315        #expect(html.contains("<figcaption>Build output</figcaption>"))
316        #expect(!html.contains("#+CAPTION"))
317        #expect(!html.contains("#+NAME"))
318    }
319
320    @Test
321    func orgLinkedImageRenders() {
322        let html = orgToHTML("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]")
323
324        #expect(html.contains(#"<a href="https://example.com">"#))
325        #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#))
326    }
327
328    @Test
329    func orgLinkedRelativeImageRenders() {
330        let html = orgToHTML(
331            "[[https://example.com/docs][[./images/badge.svg]]]",
332            imageURLResolver: { source in
333                source == "./images/badge.svg" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" : nil
334            }
335        )
336
337        #expect(html.contains(#"<a href="https://example.com/docs">"#))
338        #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#))
339    }
340
341    @Test
342    func orgNestedBulletListRendersNestedMarkup() {
343        let html = orgToHTML("""
344        * Lists
345        ** Unordered
346        - First bullet
347        - Second bullet
348        - Third bullet with /italic/ and *bold*
349        - Bullet with wrapped
350          continuation line
351        - Bullet with nested list
352          - Nested child one
353          - Nested child two
354        - Bullet with inline code ~let x = 1~
355        """)
356
357        #expect(html.contains("<ul>"))
358        #expect(html.contains("<li>Bullet with nested list\n<ul>"))
359        #expect(html.contains("<li>Nested child one</li>"))
360        #expect(html.contains("<li>Nested child two</li>"))
361        #expect(html.contains("<li>Bullet with wrapped continuation line</li>"))
362    }
363
364    @Test
365    func orgTableAlignmentRendersStyles() {
366        let html = orgToHTML("""
367        | Left | Center | Right |
368        |:-----+:-----:+------:|
369        | a    | b      | c     |
370        | 1    | 2      | 3     |
371        """)
372
373        #expect(html.contains("text-align: left;"))
374        #expect(html.contains("text-align: center;"))
375        #expect(html.contains("text-align: right;"))
376    }
377}
378
379struct RepositoryAssetURLTests {
380
381    @Test
382    func repositoryAssetURLPercentEncodesImagePaths() {
383        let url = resolveRepositoryAssetURL(
384            "images/My Logo.png",
385            owner: "~ccleberg",
386            repositoryName: "Hutch",
387            readmePath: "README.md"
388        )
389
390        #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
391    }
392}
393
394struct RepositoryLinkURLTests {
395
396    @Test
397    func repositoryLinkURLResolvesRelativePath() {
398        let url = resolveRepositoryLinkURL(
399            "LICENSE",
400            owner: "~ccleberg",
401            repositoryName: "Hutch",
402            readmePath: "README.md"
403        )
404
405        #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/LICENSE")
406    }
407
408    @Test
409    func repositoryLinkURLPassesThroughAbsoluteURL() {
410        let url = resolveRepositoryLinkURL(
411            "https://example.com/page",
412            owner: "~ccleberg",
413            repositoryName: "Hutch",
414            readmePath: "README.md"
415        )
416
417        #expect(url == "https://example.com/page")
418    }
419
420    @Test
421    func repositoryLinkURLPassesThroughFragment() {
422        let url = resolveRepositoryLinkURL(
423            "#install",
424            owner: "~ccleberg",
425            repositoryName: "Hutch",
426            readmePath: "README.md"
427        )
428
429        #expect(url == "#install")
430    }
431
432    @Test
433    func repositoryLinkURLPassesThroughMailto() {
434        let url = resolveRepositoryLinkURL(
435            "mailto:hello@example.com",
436            owner: "~ccleberg",
437            repositoryName: "Hutch",
438            readmePath: "README.md"
439        )
440
441        #expect(url == "mailto:hello@example.com")
442    }
443
444    @Test
445    func repositoryLinkURLResolvesSubdirectoryRelativePath() {
446        let url = resolveRepositoryLinkURL(
447            "docs/SECURITY.md",
448            owner: "~ccleberg",
449            repositoryName: "Hutch",
450            readmePath: "README.md"
451        )
452
453        #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/docs/SECURITY.md")
454    }
455}