markdown_test.go
| 1 | package markdown |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | func 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 | |
| 29 | func 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 | |
| 64 | func 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 | |
| 96 | func 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 | |
| 112 | func 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 | |
| 123 | func 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) ", "", 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 | |
| 143 | func 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 | |
| 158 | func 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", |
| 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. |
| 180 | func 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. |
| 193 | func 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 | |
| 206 | func 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 |