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("![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 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}