v0.26.0 df1db36

markdown_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"strings"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11)
12
13func renderMD(t *testing.T, src string) (string, []Heading) {
14	t.Helper()
15	h, outline, err := newMarkdown().render([]byte(src), "demo", "main", "docs")
16	require.NoError(t, err)
17	return string(h), outline
18}
19
20// AC4: raw HTML, javascript: links and SVG are neutralised.
21func TestMarkdownNeutralisation(t *testing.T) {
22	t.Parallel()
23	tests := []struct {
24		name, src string
25		absent    []string
26		present   []string
27	}{
28		{
29			"raw html block", "<script>alert(1)</script>\n\n<div onclick=\"x()\">hi</div>\n",
30			[]string{"<script", "<div", "onclick", "alert(1)</"}, nil,
31		},
32		{
33			"raw inline html", "a <img src=x onerror=alert(1)> b <b>c</b> <iframe src=//evil></iframe>\n",
34			[]string{"<img", "onerror", "<iframe", "<b>"}, []string{"<p>a "},
35		},
36		{
37			"javascript link", "[x](javascript:alert(1)) [y](JaVaScRiPt:alert(1)) [z](java\tscript:alert(1))\n",
38			[]string{"javascript", "JaVaScRiPt", "href=\"java"}, []string{"<p>x y "},
39		},
40		{
41			"data and other schemes", "[a](data:text/html,<script>1</script>) [b](vbscript:x) [c](file:///etc/passwd) [d](mailto:a@b.c) [e](//evil.example/x) [f](ftp://h/x)\n",
42			[]string{"href=\"data", "vbscript", "file:", "mailto", "evil.example", "ftp:"}, []string{"<p>a b c d e f</p>"},
43		},
44		{
45			"autolinks", "<javascript:alert(1)> <https://ok.example/a> <me@example.org>\n",
46			[]string{"href=\"javascript", "mailto:"}, []string{`href="https://ok.example/a"`, "me@example.org"},
47		},
48		{
49			"svg and images", "![logo](x.svg) ![remote](https://evil.example/p.png) ![](data:image/svg+xml;base64,PHN2Zz4=)\n\n<svg onload=alert(1)><circle/></svg>\n",
50			[]string{"<img", "<svg", "onload", "evil.example", "base64", "src="}, []string{`<span class="alt">[logo]</span>`, `<span class="alt">[remote]</span>`, `<span class="alt">[image]</span>`},
51		},
52		{
53			"escaped text", "# <b>x</b> & \"q\"\n\nplain <u>tags</u> & \"quotes\"\n",
54			[]string{"<b>", "<u>"}, []string{"&amp;", "&quot;"},
55		},
56		{
57			"title injection", "[x](https://a.example \"t\\\" onmouseover=\\\"alert(1)\")\n",
58			[]string{"onmouseover=\"", " title="}, []string{`href="https://a.example"`},
59		},
60	}
61	for _, tt := range tests {
62		t.Run(tt.name, func(t *testing.T) {
63			t.Parallel()
64			out, _ := renderMD(t, tt.src)
65			for _, s := range tt.absent {
66				assert.NotContains(t, out, s)
67			}
68			for _, s := range tt.present {
69				assert.Contains(t, out, s)
70			}
71			// Whatever the input: no inline script, no event handler, no
72			// style, no element that loads something.
73			for _, banned := range []string{"<script", "<style", "<svg", "<img", "<iframe", "<object", "<embed", "<form", " on", "style="} {
74				if banned == " on" {
75					assert.NotRegexp(t, ` on[a-z]+=`, out)
76					continue
77				}
78				assert.NotContains(t, strings.ToLower(out), banned)
79			}
80		})
81	}
82}
83
84func TestMarkdownLinks(t *testing.T) {
85	t.Parallel()
86	tests := []struct{ name, src, href string }{
87		{"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`},
88		{"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`},
89		{"cannot climb out of the repository", "[a](../../../../etc/passwd)", `href="/demo/blob/main/etc/passwd"`},
90		{"root relative", "[a](/src/main.go)", `href="/demo/blob/main/src/main.go"`},
91		{"fragment on a file", "[a](x.md#part)", `href="/demo/blob/main/docs/x.md#part"`},
92		{"dot is the repository", "[a](../)", `href="/demo"`},
93		{"escaped segments", "[a](a%20b/c%3Fd.md)", `href="/demo/blob/main/docs/a%20b/c%3Fd.md"`},
94		{"https is external", "[a](https://example.org/p?q=1)", `href="https://example.org/p?q=1" rel="nofollow noopener noreferrer"`},
95		{"http too", "[a](http://example.org/)", `rel="nofollow noopener noreferrer"`},
96		{"heading anchor", "[a](#Install Now)", ``},
97		{"heading anchor ok", "[a](#install)", `href="#md-install"`},
98	}
99	for _, tt := range tests {
100		t.Run(tt.name, func(t *testing.T) {
101			t.Parallel()
102			out, _ := renderMD(t, tt.src)
103			if tt.href == "" {
104				assert.NotContains(t, out, "<a ")
105				return
106			}
107			assert.Contains(t, out, tt.href)
108		})
109	}
110}
111
112func TestMarkdownOutline(t *testing.T) {
113	t.Parallel()
114	out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n")
115	assert.Equal(t, []Heading{
116		{Level: 1, ID: "md-title", Text: "Title"},
117		{Level: 2, ID: "md-install-now", Text: "Install now"},
118		{Level: 3, ID: "md-install-now-1", Text: "Install now"},
119		{Level: 4, ID: "md-raw-em", Text: "raw em"},
120	}, outline, "ids are prefixed and unique, text has no markup")
121	for _, h := range outline {
122		assert.Contains(t, out, `id="`+h.ID+`"`)
123	}
124	// A heading cannot take the id of a page element.
125	out, outline = renderMD(t, "# main\n\n# keys\n")
126	assert.Equal(t, "md-main", outline[0].ID)
127	assert.NotContains(t, out, `id="main"`)
128	assert.NotContains(t, out, `id="keys"`)
129}
130
131func TestMarkdownGFM(t *testing.T) {
132	t.Parallel()
133	out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n")
134	assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style")
135	assert.NotContains(t, out, "style=")
136	assert.Contains(t, out, "<del>gone</del>")
137	assert.Contains(t, out, `type="checkbox"`)
138}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close