package markdown
import (
"strings"
"testing"
)
func TestResolveHrefNotRewritten(t *testing.T) {
cases := []struct{ dir, href string }{
{"", "http://example.com/img.png"},
{"docs", "http://example.com/img.png"},
{"", "https://example.com/img.png"},
{"", "ftp://files.example.com/"},
{"", "mailto:foo@bar.com"},
{"", "data:image/png;base64,abc123"},
{"", "ssh://git@example.com/repo.git"},
{"", "myapp://open/something"},
{"", "#section-heading"},
{"docs/guide", "#toc"},
{"", "#"},
}
for _, c := range cases {
if got, ok := ResolveHref(c.dir, c.href); ok {
t.Errorf("ResolveHref(%q, %q) = %q, want no rewrite", c.dir, c.href, got)
}
}
}
func TestResolveHref(t *testing.T) {
cases := []struct{ dir, href, want string }{
// Root-relative: dir is ignored.
{"", "/img.png", "img.png"},
{"docs", "/subdir/img.png", "subdir/img.png"},
{"x/y/z", "/a/b/c.png", "a/b/c.png"},
{"docs", "/assets/", "assets/"},
// Path-relative from the repo root.
{"", "img.png", "img.png"},
{"", "./img.png", "img.png"},
{"", "subdir/img.png", "subdir/img.png"},
{"", "./subdir/img.png", "subdir/img.png"},
{"", "../img.png", "img.png"},
// Path-relative from one level down.
{"docs", "img.png", "docs/img.png"},
{"docs", "./img.png", "docs/img.png"},
{"docs", "../img.png", "img.png"},
{"docs", "subdir/img.png", "docs/subdir/img.png"},
{"docs", "./subdir/img.png", "docs/subdir/img.png"},
// Path-relative from a nested directory.
{"docs/guide", "img.png", "docs/guide/img.png"},
{"docs/guide", "../img.png", "docs/img.png"},
{"docs/guide", "../../img.png", "img.png"},
{"docs/guide", "../assets/img.png", "docs/assets/img.png"},
{"docs/guide", "./img.png", "docs/guide/img.png"},
{"docs/guide", "sub/img.png", "docs/guide/sub/img.png"},
}
for _, c := range cases {
got, ok := ResolveHref(c.dir, c.href)
if !ok || got != c.want {
t.Errorf("ResolveHref(%q, %q) = %q,%v want %q,true", c.dir, c.href, got, ok, c.want)
}
}
}
func TestRenderGFM(t *testing.T) {
r := New()
cases := []struct {
name string
in string
want []string
}{
{
"table alignment", "| a | b |\n|---|:-:|\n| 1 | 2 |",
[]string{"
", "
a
", `
b
`, `
2
`},
},
{"strikethrough", "~~gone~~", []string{"gone"}},
{
"task list", "- [x] done\n- [ ] todo",
[]string{``}},
{
"details survive sanitization", "sbody",
[]string{"", "s", "body", ""},
},
}
for _, c := range cases {
got := r.Render(c.in, "", nil)
for _, want := range c.want {
if !strings.Contains(got, want) {
t.Errorf("%s: %q is missing %q", c.name, got, want)
}
}
}
}
func TestRenderFencedCodeIsHighlighted(t *testing.T) {
r := New()
got := r.Render("```js\nlet x=1;\n```", "", nil)
if !strings.Contains(got, `class="language-js"`) {
t.Errorf("missing language class: %q", got)
}
if !strings.Contains(got, `alert(1)", "", nil)
if strings.Contains(got, "script") || strings.Contains(got, "onerror") {
t.Errorf("unsafe html survived: %q", got)
}
if got := r.Render("[x](javascript:alert(1))", "", nil); strings.Contains(got, "javascript:") {
t.Errorf("javascript link survived: %q", got)
}
}
func TestRenderRepoContext(t *testing.T) {
r := New()
ctx := &Context{Repo: "myrepo", Ref: "main", Dir: "docs"}
got := r.Render("[r](./a.md) [abs](/b.md) [ext](https://e.com) [an](#s) ", "", ctx)
for _, want := range []string{
`r`,
`abs`,
`ext`,
`an`,
`src="/myrepo/raw/main/docs/x.png"`,
} {
if !strings.Contains(got, want) {
t.Errorf("%q is missing %q", got, want)
}
}
if got := r.Render("[r](./a.md)", "", nil); !strings.Contains(got, `href="./a.md"`) {
t.Errorf("without context the href must stay: %q", got)
}
}
func TestRenderCache(t *testing.T) {
r := New()
first := r.Render("# hello", "key", nil)
// A cache hit must ignore the new source.
if second := r.Render("# different", "key", nil); second != first {
t.Errorf("cache miss: %q != %q", second, first)
}
for i := range maxMDCache + 5 {
r.Render("x", string(rune('a'+i%26))+string(rune(i)), nil)
}
if r.cache.Len() > maxMDCache {
t.Errorf("cache grew to %d, want at most %d", r.cache.Len(), maxMDCache)
}
}
func TestToPlaintext(t *testing.T) {
cases := []struct{ name, in, want string }{
{"unordered list", "- a\n- b", "- a\n- b"},
{"ordered list keeps its start", "3. a\n4. b", "3. a\n4. b"},
{"task list markers", "- [x] done\n- [ ] todo\n- plain", "- [x] done\n- [ ] todo\n- plain"},
{"table cells are pipe-joined", "| a | b |\n|---|---|\n| 1 | 2 |", "a | b\n1 | 2"},
{"blockquote keeps its marker", "> one\n> two", "> one\n> two"},
{
"inline markup is unwrapped", "# Title\n\nSome **bold** [link](https://e.com) and `code`.\n\n---\n\n",
"Title\n\nSome bold link and `code`.\n\n---\n\n[Image: alt]",
},
{"raw html is stripped", "a b c\n\n
d
", "a b c"},
{"empty input", "", ""},
}
for _, c := range cases {
if got := ToPlaintext(c.in); got != c.want {
t.Errorf("%s: ToPlaintext(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
// The stylesheet selects on both classes, so the markup must stay exactly this.
func TestTaskListMarkup(t *testing.T) {
want := `
done
todo
`
if got := New().Render("- [x] done\n- [ ] todo\n", "", nil); got != want {
t.Errorf("task list markup:\n got %q\nwant %q", got, want)
}
}
// A README at one commit is reachable under every ref that points at it. The
// rewritten links carry the ref, so the ref has to be part of the cache key.
func TestRenderCacheKeyIncludesRef(t *testing.T) {
r := New()
key := "readme:repo:README.md:"
main := r.Render("[doc](doc.md)", key, &Context{Repo: "repo", Ref: "main"})
tag := r.Render("[doc](doc.md)", key, &Context{Repo: "repo", Ref: "v1.0"})
if !strings.Contains(main, "/blob/main/") {
t.Fatalf("main render lost its ref: %s", main)
}
if !strings.Contains(tag, "/blob/v1.0/") {
t.Errorf("tag render served the cached main entry: %s", tag)
}
}
func TestSiteClassesAreStripped(t *testing.T) {
r := New()
got := r.Render(`Merged
x
y`, "", nil)
if strings.Contains(got, "class=") {
t.Errorf("spoof class survived: %q", got)
}
}