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("")
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("[](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("<b>"))
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("")
177
178 // `&` 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 "&" in the query string.
181 #expect(html.contains("metric=security_rating"))
182 #expect(!html.contains("&amp;"))
183 #expect(html.contains("<img"))
184 }
185
186 @Test
187 func markdownLinkedImagesWithQueryStringsRenderAllImages() {
188 let html = markdownToHTML("""
189 [](https://builds.sr.ht/~ccleberg/Hutch?)
190 [](https://sonarcloud.io/summary/new_code?id=ccleberg_Hutch)
191 [](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] <FILE></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}