markdown_test.go
⎇
Raw
1package markdown
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestResolveHrefNotRewritten(t *testing.T) {
9 cases := []struct{ dir, href string }{
10 {"", "http://example.com/img.png"},
11 {"docs", "http://example.com/img.png"},
12 {"", "https://example.com/img.png"},
13 {"", "ftp://files.example.com/"},
14 {"", "mailto:foo@bar.com"},
15 {"", "data:image/png;base64,abc123"},
16 {"", "ssh://git@example.com/repo.git"},
17 {"", "myapp://open/something"},
18 {"", "#section-heading"},
19 {"docs/guide", "#toc"},
20 {"", "#"},
21 }
22 for _, c := range cases {
23 if got, ok := ResolveHref(c.dir, c.href); ok {
24 t.Errorf("ResolveHref(%q, %q) = %q, want no rewrite", c.dir, c.href, got)
25 }
26 }
27}
28
29func TestResolveHref(t *testing.T) {
30 cases := []struct{ dir, href, want string }{
31 // Root-relative: dir is ignored.
32 {"", "/img.png", "img.png"},
33 {"docs", "/subdir/img.png", "subdir/img.png"},
34 {"x/y/z", "/a/b/c.png", "a/b/c.png"},
35 {"docs", "/assets/", "assets/"},
36 // Path-relative from the repo root.
37 {"", "img.png", "img.png"},
38 {"", "./img.png", "img.png"},
39 {"", "subdir/img.png", "subdir/img.png"},
40 {"", "./subdir/img.png", "subdir/img.png"},
41 {"", "../img.png", "img.png"},
42 // Path-relative from one level down.
43 {"docs", "img.png", "docs/img.png"},
44 {"docs", "./img.png", "docs/img.png"},
45 {"docs", "../img.png", "img.png"},
46 {"docs", "subdir/img.png", "docs/subdir/img.png"},
47 {"docs", "./subdir/img.png", "docs/subdir/img.png"},
48 // Path-relative from a nested directory.
49 {"docs/guide", "img.png", "docs/guide/img.png"},
50 {"docs/guide", "../img.png", "docs/img.png"},
51 {"docs/guide", "../../img.png", "img.png"},
52 {"docs/guide", "../assets/img.png", "docs/assets/img.png"},
53 {"docs/guide", "./img.png", "docs/guide/img.png"},
54 {"docs/guide", "sub/img.png", "docs/guide/sub/img.png"},
55 }
56 for _, c := range cases {
57 got, ok := ResolveHref(c.dir, c.href)
58 if !ok || got != c.want {
59 t.Errorf("ResolveHref(%q, %q) = %q,%v want %q,true", c.dir, c.href, got, ok, c.want)
60 }
61 }
62}
63
64func TestRenderGFM(t *testing.T) {
65 r := New()
66 cases := []struct {
67 name string
68 in string
69 want []string
70 }{
71 {
72 "table alignment", "| a | b |\n|---|:-:|\n| 1 | 2 |",
73 []string{"<table>", "<th>a</th>", `<th align="center">b</th>`, `<td align="center">2</td>`},
74 },
75 {"strikethrough", "~~gone~~", []string{"<del>gone</del>"}},
76 {
77 "task list", "- [x] done\n- [ ] todo",
78 []string{`<input`, `checked`, `disabled`, `type="checkbox"`},
79 },
80 {"autolink", "see https://example.com ok", []string{`<a href="https://example.com">`}},
81 {
82 "details survive sanitization", "<details><summary>s</summary>body</details>",
83 []string{"<details>", "<summary>s</summary>", "body", "</details>"},
84 },
85 }
86 for _, c := range cases {
87 got := r.Render(c.in, "", nil)
88 for _, want := range c.want {
89 if !strings.Contains(got, want) {
90 t.Errorf("%s: %q is missing %q", c.name, got, want)
91 }
92 }
93 }
94}
95
96func TestRenderFencedCodeIsHighlighted(t *testing.T) {
97 r := New()
98 got := r.Render("```js\nlet x=1;\n```", "", nil)
99 if !strings.Contains(got, `class="language-js"`) {
100 t.Errorf("missing language class: %q", got)
101 }
102 if !strings.Contains(got, `<span class="ch-`) {
103 t.Errorf("code was not highlighted: %q", got)
104 }
105 // An unknown language stays plain but keeps its class.
106 got = r.Render("```nosuchlang\nx\n```", "", nil)
107 if !strings.Contains(got, `class="language-nosuchlang"`) || strings.Contains(got, "ch-") {
108 t.Errorf("unknown language: %q", got)
109 }
110}
111
112func TestRenderSanitizes(t *testing.T) {
113 r := New()
114 got := r.Render("<script>alert(1)</script><img src=x onerror=alert(1)>", "", nil)
115 if strings.Contains(got, "script") || strings.Contains(got, "onerror") {
116 t.Errorf("unsafe html survived: %q", got)
117 }
118 if got := r.Render("[x](javascript:alert(1))", "", nil); strings.Contains(got, "javascript:") {
119 t.Errorf("javascript link survived: %q", got)
120 }
121}
122
123func TestRenderRepoContext(t *testing.T) {
124 r := New()
125 ctx := &Context{Repo: "myrepo", Ref: "main", Dir: "docs"}
126 got := r.Render("[r](./a.md) [abs](/b.md) [ext](https://e.com) [an](#s) ![i](./x.png)", "", ctx)
127 for _, want := range []string{
128 `<a href="/myrepo/blob/main/docs/a.md">r</a>`,
129 `<a href="/myrepo/blob/main/b.md">abs</a>`,
130 `<a href="https://e.com">ext</a>`,
131 `<a href="#s">an</a>`,
132 `src="/myrepo/raw/main/docs/x.png"`,
133 } {
134 if !strings.Contains(got, want) {
135 t.Errorf("%q is missing %q", got, want)
136 }
137 }
138 if got := r.Render("[r](./a.md)", "", nil); !strings.Contains(got, `href="./a.md"`) {
139 t.Errorf("without context the href must stay: %q", got)
140 }
141}
142
143func TestRenderCache(t *testing.T) {
144 r := New()
145 first := r.Render("# hello", "key", nil)
146 // A cache hit must ignore the new source.
147 if second := r.Render("# different", "key", nil); second != first {
148 t.Errorf("cache miss: %q != %q", second, first)
149 }
150 for i := range maxMDCache + 5 {
151 r.Render("x", string(rune('a'+i%26))+string(rune(i)), nil)
152 }
153 if r.cache.Len() > maxMDCache {
154 t.Errorf("cache grew to %d, want at most %d", r.cache.Len(), maxMDCache)
155 }
156}
157
158func TestToPlaintext(t *testing.T) {
159 cases := []struct{ name, in, want string }{
160 {"unordered list", "- a\n- b", "- a\n- b"},
161 {"ordered list keeps its start", "3. a\n4. b", "3. a\n4. b"},
162 {"task list markers", "- [x] done\n- [ ] todo\n- plain", "- [x] done\n- [ ] todo\n- plain"},
163 {"table cells are pipe-joined", "| a | b |\n|---|---|\n| 1 | 2 |", "a | b\n1 | 2"},
164 {"blockquote keeps its marker", "> one\n> two", "> one\n> two"},
165 {
166 "inline markup is unwrapped", "# Title\n\nSome **bold** [link](https://e.com) and `code`.\n\n---\n\n![alt](x.png)",
167 "Title\n\nSome bold link and `code`.\n\n---\n\n[Image: alt]",
168 },
169 {"raw html is stripped", "a <b>b</b> c\n\n<div>d</div>", "a b c"},
170 {"empty input", "", ""},
171 }
172 for _, c := range cases {
173 if got := ToPlaintext(c.in); got != c.want {
174 t.Errorf("%s: ToPlaintext(%q) = %q, want %q", c.name, c.in, got, c.want)
175 }
176 }
177}
178
179// The stylesheet selects on both classes, so the markup must stay exactly this.
180func TestTaskListMarkup(t *testing.T) {
181 want := `<ul>
182<li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="" checked="">done</li>
183<li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="">todo</li>
184</ul>
185`
186 if got := New().Render("- [x] done\n- [ ] todo\n", "", nil); got != want {
187 t.Errorf("task list markup:\n got %q\nwant %q", got, want)
188 }
189}
190
191// A README at one commit is reachable under every ref that points at it. The
192// rewritten links carry the ref, so the ref has to be part of the cache key.
193func TestRenderCacheKeyIncludesRef(t *testing.T) {
194 r := New()
195 key := "readme:repo:README.md:"
196 main := r.Render("[doc](doc.md)", key, &Context{Repo: "repo", Ref: "main"})
197 tag := r.Render("[doc](doc.md)", key, &Context{Repo: "repo", Ref: "v1.0"})
198 if !strings.Contains(main, "/blob/main/") {
199 t.Fatalf("main render lost its ref: %s", main)
200 }
201 if !strings.Contains(tag, "/blob/v1.0/") {
202 t.Errorf("tag render served the cached main entry: %s", tag)
203 }
204}
205
206func TestSiteClassesAreStripped(t *testing.T) {
207 r := New()
208 got := r.Render(`<span class="badge badge-open">Merged</span> <div class="ch-k">x</div> <code class="language-go evil">y</code>`, "", nil)
209 if strings.Contains(got, "class=") {
210 t.Errorf("spoof class survived: %q", got)
211 }
212}
213