HutchTests/ReadmeViewTests.swift

280 lines · 9238 bytes

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