detached 27e453c

issues_form_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"fmt"
7	"net/http"
8	"net/http/httptest"
9	"net/url"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20	"bnema.dev/ermite/internal/ports/mocks"
21)
22
23type formRig struct {
24	*issueRig
25	form *mocks.IssueSubmissionService
26}
27
28func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig {
29	t.Helper()
30	r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)}
31	browse := mocks.NewBrowseService(t)
32	browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
33	r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
34	r.srv = newTestServer(t, Config{
35		Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
36		IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
37	})
38	return r
39}
40
41func challenge(n int) ports.IssueChallenge {
42	return ports.IssueChallenge{
43		Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18,
44		FormToken: fmt.Sprintf("form-%d", n),
45		IssuedAt:  time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000),
46		ExpiresAt: time.UnixMilli(1_700_000_600_000),
47	}
48}
49
50// post sends a submission as a browser of this site would.
51func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder {
52	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
53	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
54	for k, v := range hdr {
55		req.Header.Set(k, v)
56	}
57	rec := httptest.NewRecorder()
58	r.srv.ServeHTTP(rec, req)
59	return rec
60}
61
62var fromSite = map[string]string{"Origin": testPublicURL}
63
64func goodForm() url.Values {
65	return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}}
66}
67
68func TestIssueFormGet(t *testing.T) {
69	t.Parallel()
70	r := newFormRig(t, domain.IssuesPublic)
71	r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once()
72
73	rec := r.do("GET", "/proj/issues/new", false, nil)
74	require.Equal(t, 200, rec.Code, rec.Body.String())
75	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
76	body := rec.Body.String()
77	for _, want := range []string{
78		`<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.",
79		`action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80		`data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81		`name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82		`name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
83		`<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84		`<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85		`/-/static/js/issue-form.`,
86	} {
87		assert.Contains(t, body, want)
88	}
89	assert.NotContains(t, body, `maxlength`, "the limits count code points, not UTF-16 units")
90	assert.NotContains(t, body, `name="website" type="hidden"`)
91	assert.NotContains(t, body, `type="hidden" name="website"`)
92	noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
93	assert.NotContains(t, noscript, "<form", "no usable form without JavaScript")
94	assert.NotContains(t, body, "style=", "no inline style")
95	for _, tag := range strings.Split(body, "<script")[1:] {
96		head, _, _ := strings.Cut(tag, ">")
97		assert.Contains(t, head, `src="`, "no inline script")
98	}
99}
100
101func TestIssueFormIsNotThereUnlessPublic(t *testing.T) {
102	t.Parallel()
103	want := ""
104	for i, tc := range []struct {
105		name string
106		mode domain.IssuesMode
107		err  error
108	}{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} {
109		r := newFormRig(t, tc.mode)
110		r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe()
111		path := "/proj/issues/new"
112		if tc.name == "unknown" {
113			path = "/nope/issues/new"
114		}
115		rec := r.do("GET", path, false, nil)
116		assert.Equal(t, 404, rec.Code, tc.name)
117		if i == 0 {
118			want = rec.Body.String()
119			continue
120		}
121		assert.Equal(t, want, rec.Body.String(), tc.name+": the same page")
122	}
123}
124
125func TestIssueFormOwnerKeepsHisForm(t *testing.T) {
126	t.Parallel()
127	r := newFormRig(t, domain.IssuesPublic) // no Challenge expected
128	rec := r.do("GET", "/proj/issues/new", true, nil)
129	require.Equal(t, 200, rec.Code)
130	assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
131	assert.NotContains(t, rec.Body.String(), "data-issue-form")
132}
133
134func TestIssueFormGetPausedAndLimited(t *testing.T) {
135	t.Parallel()
136	r := newFormRig(t, domain.IssuesPublic)
137	r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once()
138	rec := r.do("GET", "/proj/issues/new", false, nil)
139	assert.Equal(t, 503, rec.Code)
140	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
141	assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.")
142}
143
144func TestIssueFormPostOutcomes(t *testing.T) {
145	t.Parallel()
146	generic := "The submission could not be verified. Reload the page and try again."
147	tests := []struct {
148		name   string
149		err    error
150		status int
151		body   string
152		hdr    map[string]string
153	}{
154		{"not found", domain.ErrNotFound, 404, "no such page", nil},
155		{"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}},
156		{"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}},
157		{"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil},
158		{"invalid", domain.ErrSubmissionInvalid, 400, generic, nil},
159		{"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil},
160	}
161	for _, tc := range tests {
162		t.Run(tc.name, func(t *testing.T) {
163			t.Parallel()
164			r := newFormRig(t, domain.IssuesPublic)
165			r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once()
166			rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
167			assert.Equal(t, tc.status, rec.Code, rec.Body.String())
168			assert.Contains(t, rec.Body.String(), tc.body)
169			assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
170			for k, v := range tc.hdr {
171				assert.Equal(t, v, rec.Header().Get(k))
172			}
173			assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed")
174			for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} {
175				assert.NotContains(t, rec.Body.String(), leak, "no detail")
176			}
177		})
178	}
179}
180
181// TestIssueFormPostInvalidTokenLooksTheSame: the use case answers an invalid
182// submission the same whatever the client's ban, limit or the queue; the page
183// must not differ either.
184func TestIssueFormPostInvalidTokenLooksTheSame(t *testing.T) {
185	t.Parallel()
186	var first *httptest.ResponseRecorder
187	for _, state := range []string{"banned", "rate limited", "queue full"} {
188		r := newFormRig(t, domain.IssuesPublic)
189		r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(domain.ErrSubmissionInvalid).Once()
190		f := goodForm()
191		f.Set("token", "garbage")
192		rec := r.post("/proj/issues/new", fromSite, f.Encode())
193		assert.Equal(t, 400, rec.Code, state)
194		if first == nil {
195			first = rec
196			continue
197		}
198		assert.Equal(t, first.Body.String(), rec.Body.String(), state)
199		assert.Equal(t, first.Header().Get("Retry-After"), rec.Header().Get("Retry-After"), state)
200	}
201}
202
203func TestIssueFormPostSuccess(t *testing.T) {
204	t.Parallel()
205	r := newFormRig(t, domain.IssuesPublic)
206	r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{
207		Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex",
208	}).Return(nil).Once()
209	rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
210	assert.Equal(t, 303, rec.Code)
211	assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location"))
212	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
213
214	thanks := r.do("GET", "/proj/issues/submitted", false, nil)
215	assert.Equal(t, 200, thanks.Code)
216	assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control"))
217	assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.")
218}
219
220func TestIssueSubmittedPageOnlyForPublic(t *testing.T) {
221	t.Parallel()
222	r := newFormRig(t, domain.IssuesOwner)
223	assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code)
224}
225
226func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) {
227	t.Parallel()
228	r := newFormRig(t, domain.IssuesPublic)
229	r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
230		Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once()
231	r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once()
232	f := goodForm()
233	f.Set("title", "<b>mine</b>")
234	rec := r.post("/proj/issues/new", fromSite, f.Encode())
235	require.Equal(t, 422, rec.Code, rec.Body.String())
236	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
237	body := rec.Body.String()
238	assert.Contains(t, body, "&lt;b&gt;mine&lt;/b&gt;", "the title is kept")
239	assert.Contains(t, body, "line1\nline2", "the body is kept")
240	assert.Contains(t, body, `value="Alex"`)
241	assert.Contains(t, body, "The title is empty.")
242	assert.Contains(t, body, `name="token" value="tok-2.sig"`, "a new challenge")
243	assert.NotContains(t, body, "tok-1.sig", "the spent token is not echoed")
244}
245
246func TestIssueFormPostLimits(t *testing.T) {
247	t.Parallel()
248	t.Run("oversize", func(t *testing.T) {
249		t.Parallel()
250		r := newFormRig(t, domain.IssuesPublic) // Submit is never called
251		big := url.Values{"title": {"x"}, "body": {strings.Repeat("a", maxIssueFormBytes+10)}}
252		rec := r.post("/proj/issues/new", fromSite, big.Encode())
253		assert.Equal(t, 413, rec.Code)
254		assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
255	})
256	t.Run("duplicated fields", func(t *testing.T) {
257		t.Parallel()
258		for _, f := range issueFormFields {
259			r := newFormRig(t, domain.IssuesPublic)
260			rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()+"&"+f+"=again")
261			assert.Equal(t, 400, rec.Code, f)
262			assert.Contains(t, rec.Body.String(), "could not be verified", f)
263		}
264	})
265}
266
267// The gate still demands the Origin of this site for the anonymous POST.
268func TestIssueFormPostNeedsTheOrigin(t *testing.T) {
269	t.Parallel()
270	r := newFormRig(t, domain.IssuesPublic) // Submit is never called
271	for name, hdr := range map[string]map[string]string{
272		"missing":                 nil,
273		"cross origin":            {"Origin": "https://evil.example"},
274		"null":                    {"Origin": "null"},
275		"same host, other scheme": {"Origin": "http://forge.example"},
276	} {
277		rec := r.post("/proj/issues/new", hdr, goodForm().Encode())
278		assert.Equal(t, 403, rec.Code, name)
279	}
280}
281
282func TestIssueFormPostOnOwnerModeIsNotFound(t *testing.T) {
283	t.Parallel()
284	r := newFormRig(t, domain.IssuesOwner)
285	r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once()
286	rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
287	assert.Equal(t, 404, rec.Code)
288}
289
290// anonymousPost opens exactly /<repo>/issues/new and nothing else.
291func TestAnonymousPostOpensOnlyTheIssueForm(t *testing.T) {
292	t.Parallel()
293	r := newFormRig(t, domain.IssuesPublic)
294	for _, p := range []string{
295		"/x/issues/new/extra", "/x/issues/123", "/x/issues", "/x/issues/new/", "/x/issues/123/close", "/x/issues/new/close",
296		"/x%2Fissues/new", "/x%2Fy/issues/new", "/%2Fissues/new", "/issues/new", "//issues/new", "/X/issues/new",
297		"/a/b/issues/new", "/x.git/issues/new", "/../issues/new", "/-/inbox/pending/x/y/approve", "/x/merge-requests",
298		"/x/issues%2Fnew", "/x/issues/new%2Fextra", "/x/issues/%6Eew",
299	} {
300		u := &url.URL{Path: p}
301		if dec, err := url.PathUnescape(p); err == nil {
302			u = &url.URL{Path: dec, RawPath: p}
303		}
304		req := &http.Request{Method: http.MethodPost, URL: u}
305		got := r.srv.anonymousPost(req)
306		want := false
307		assert.Equal(t, want, got, p)
308	}
309	for _, p := range []string{"/x/issues/new", "/proj/issues/new", "/a.b_c-d/issues/new"} {
310		req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: p}}
311		assert.True(t, r.srv.anonymousPost(req), p)
312		req.Method = http.MethodPut
313		assert.False(t, r.srv.anonymousPost(req), p+" PUT")
314	}
315	// Without the use case the path is not opened at all.
316	plain := newIssueRig(t, domain.IssuesPublic, false)
317	req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/proj/issues/new"}}
318	assert.False(t, plain.srv.anonymousPost(req))
319}
320
321func TestIssueListShowsNewIssueToVisitorsOfPublicRepos(t *testing.T) {
322	t.Parallel()
323	for mode, want := range map[domain.IssuesMode]bool{domain.IssuesPublic: true, domain.IssuesOwner: false} {
324		r := newFormRig(t, mode)
325		r.issues.EXPECT().List(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.IssuePage{}, nil)
326		rec := r.do("GET", "/proj/issues", false, nil)
327		require.Equal(t, 200, rec.Code)
328		assert.Equal(t, want, strings.Contains(rec.Body.String(), `href="/proj/issues/new"`), string(mode))
329	}
330}
331
332func TestExternalIssueIsRenderedUntrusted(t *testing.T) {
333	t.Parallel()
334	r := newFormRig(t, domain.IssuesPublic)
335	i := testIssue(t, 5, domain.IssueStateOpen, "bug", "[a](javascript:alert(1)) [b](https://example.org/) <script>x</script>")
336	i.External, i.Author = true, "Eve <b>"
337	r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{Issue: i}, nil)
338	rec := r.do("GET", "/proj/issues/5", false, nil)
339	require.Equal(t, 200, rec.Code)
340	body := rec.Body.String()
341	assert.NotContains(t, body, "javascript:")
342	assert.NotContains(t, body, "<script>x")
343	assert.Contains(t, body, `rel="nofollow noopener noreferrer ugc"`)
344	assert.Contains(t, body, "Eve &lt;b&gt;")
345}

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