v0.31.0 d5e0505

web_issue_form_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"crypto/sha256"
7	"encoding/base64"
8	"html"
9	"math/bits"
10	"net/url"
11	"regexp"
12	"strconv"
13	"testing"
14
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22var (
23	reFormAttr  = func(name string) *regexp.Regexp { return regexp.MustCompile(`data-` + name + `="([^"]*)"`) }
24	reFormField = func(name string) *regexp.Regexp {
25		return regexp.MustCompile(`<input type="hidden" name="` + name + `" value="([^"]*)"`)
26	}
27	rePendingActions = regexp.MustCompile(`action="(/-/inbox/pending/[^"]+/(approve|reject|ban))"`)
28)
29
30// testPoW is the difficulty the tests lower the proof of work to.
31const testPoW = 8
32
33// solvePoW finds the smallest counter n with SHA-256(nonce || ":" || n) having
34// bits leading zero bits, as the browser does.
35func solvePoW(t *testing.T, nonce string, difficulty int) string {
36	t.Helper()
37	raw, err := url.PathUnescape(nonce)
38	require.NoError(t, err)
39	b, err := decodeB64URL(raw)
40	require.NoError(t, err)
41	for n := 0; n < 1<<26; n++ {
42		sum := sha256.Sum256(append(append(append([]byte{}, b...), ':'), strconv.Itoa(n)...))
43		z := 0
44		for _, c := range sum {
45			if c == 0 {
46				z += 8
47				continue
48			}
49			z += bits.LeadingZeros8(c)
50			break
51		}
52		if z >= difficulty {
53			return strconv.Itoa(n)
54		}
55	}
56	t.Fatal("no solution")
57	return ""
58}
59
60// formValues reads the challenge out of the HTML of the public form and
61// solves it.
62func formValues(t *testing.T, body string) url.Values {
63	t.Helper()
64	get := func(re *regexp.Regexp) string {
65		m := re.FindStringSubmatch(body)
66		require.Len(t, m, 2, "the form carries %s", re)
67		return html.UnescapeString(m[1])
68	}
69	diff, err := strconv.Atoi(get(reFormAttr("difficulty")))
70	require.NoError(t, err)
71	require.Equal(t, testPoW, diff, "the test difficulty")
72	return url.Values{
73		"token": {get(reFormField("token"))}, "form_token": {get(reFormField("form_token"))},
74		"solution": {solvePoW(t, get(reFormAttr("nonce")), diff)},
75	}
76}
77
78// The public issue form end to end: a visitor solves the proof of work and
79// submits, the owner sees the issue awaiting moderation in the inbox and
80// approves it, and a rejection with a ban shuts the same address out.
81func TestWebPublicIssueFormEndToEnd(t *testing.T) {
82	h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
83	cli := &cliHarness{harness: h, url: h.srv.URL}
84	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
85	require.NoError(t, err)
86	b := browser{h}
87	token := cli.signIn(t)
88
89	pub := domain.IssuesPublic
90	_, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
91	require.NoError(t, err)
92	site := map[string]string{"Origin": publicOrigin}
93
94	// The visitor reads the list and finds the button.
95	list := b.get("/proj/issues", nil)
96	require.Equal(t, 200, list.status, list.body)
97	assert.Contains(t, list.body, `href="/proj/issues/new"`)
98
99	// The form, with a challenge, never stored.
100	form := b.get("/proj/issues/new", nil)
101	require.Equal(t, 200, form.status, form.body)
102	assert.Equal(t, "no-store", form.hdr.Get("Cache-Control"))
103	assert.Contains(t, form.body, "<noscript>")
104	assert.Contains(t, form.body, `name="website"`)
105	v := formValues(t, form.body)
106	v.Set("title", "Search is <b>slow</b>")
107	v.Set("body", "see [docs](https://example.org/d) and [x](javascript:alert(1))\r\n**bold**")
108	v.Set("author", "Alex")
109
110	// A bad cross-origin POST is refused before anything is checked.
111	assert.Equal(t, 403, b.do("POST", "/proj/issues/new", nil, v.Encode()).status)
112	assert.Equal(t, 403, b.do("POST", "/proj/issues/new", map[string]string{"Origin": "https://evil.example"}, v.Encode()).status)
113
114	// The submission passes.
115	sent := b.do("POST", "/proj/issues/new", site, v.Encode())
116	require.Equal(t, 303, sent.status, sent.body)
117	assert.Equal(t, "/proj/issues/submitted", sent.hdr.Get("Location"))
118	assert.Equal(t, "no-store", sent.hdr.Get("Cache-Control"))
119	thanks := b.get("/proj/issues/submitted", nil)
120	assert.Equal(t, 200, thanks.status)
121	assert.Contains(t, thanks.body, "Thanks, your issue is waiting for moderation.")
122
123	// The same challenge cannot be used twice.
124	replay := b.do("POST", "/proj/issues/new", site, v.Encode())
125	assert.Equal(t, 400, replay.status)
126	assert.Contains(t, replay.body, "could not be verified")
127
128	// Nothing is public yet.
129	assert.NotContains(t, b.get("/proj/issues", nil).body, "slow")
130
131	// A text error shows the form again with a new challenge and the text.
132	form2 := b.get("/proj/issues/new", nil)
133	v2 := formValues(t, form2.body)
134	v2.Set("title", "")
135	v2.Set("body", "kept text")
136	again := b.do("POST", "/proj/issues/new", site, v2.Encode())
137	require.Equal(t, 422, again.status, again.body)
138	assert.Contains(t, again.body, "kept text")
139	assert.Contains(t, again.body, "The title is empty.")
140	v3 := formValues(t, again.body)
141	assert.NotEqual(t, v2.Get("token"), v3.Get("token"))
142
143	// The owner finds it awaiting moderation, as plain text.
144	inbox := b.get("/-/inbox", as(token))
145	require.Equal(t, 200, inbox.status, inbox.body)
146	assert.Contains(t, inbox.body, "<strong>1</strong> awaiting moderation")
147	assert.Contains(t, inbox.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
148	assert.Contains(t, inbox.body, "**bold**")
149	assert.NotContains(t, inbox.body, "<strong>bold")
150	acts := rePendingActions.FindAllStringSubmatch(inbox.body, -1)
151	require.Len(t, acts, 3)
152	actions := map[string]string{}
153	for _, a := range acts {
154		actions[a[2]] = a[1]
155	}
156
157	// An anonymous POST to a moderation path is refused.
158	assert.Equal(t, 403, b.do("POST", actions["approve"], site, "").status)
159
160	// The owner shares the numbers with merge requests and issues: approve.
161	ok := b.form(actions["approve"], token, nil)
162	require.Equal(t, 303, ok.status, ok.body)
163	assert.Equal(t, "/-/inbox", ok.hdr.Get("Location"))
164	assert.Equal(t, 404, b.form(actions["approve"], token, nil).status, "approved once")
165	shown := b.get("/proj/issues/1", nil)
166	require.Equal(t, 200, shown.status, shown.body)
167	assert.Contains(t, shown.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
168	assert.Contains(t, shown.body, `href="https://example.org/d"`)
169	assert.Contains(t, shown.body, `rel="nofollow noopener noreferrer ugc"`)
170	assert.NotContains(t, shown.body, "javascript:")
171	assert.Contains(t, shown.body, "Alex")
172	assert.Contains(t, b.get("/proj/issues", nil).body, `href="/proj/issues/1"`)
173	empty := b.get("/-/inbox", as(token))
174	assert.Contains(t, empty.body, "<strong>0</strong> awaiting moderation")
175
176	// Reject and ban: the same address is then refused with the generic page.
177	form4 := b.get("/proj/issues/new", nil)
178	v4 := formValues(t, form4.body)
179	v4.Set("title", "spam")
180	require.Equal(t, 303, b.do("POST", "/proj/issues/new", site, v4.Encode()).status)
181	inbox2 := b.get("/-/inbox", as(token))
182	var ban string
183	for _, a := range rePendingActions.FindAllStringSubmatch(inbox2.body, -1) {
184		if a[2] == "ban" {
185			ban = a[1]
186		}
187	}
188	require.NotEmpty(t, ban)
189	require.Equal(t, 303, b.form(ban, token, nil).status)
190
191	form5 := b.get("/proj/issues/new", nil)
192	require.Equal(t, 200, form5.status, "the form is still served; the ban bites at submission")
193	v5 := formValues(t, form5.body)
194	v5.Set("title", "more spam")
195	banned := b.do("POST", "/proj/issues/new", site, v5.Encode())
196	assert.Equal(t, 400, banned.status)
197	assert.Contains(t, banned.body, "The submission could not be verified. Reload the page and try again.")
198	assert.NotContains(t, banned.body, "ban")
199	assert.Contains(t, b.get("/-/inbox", as(token)).body, "<strong>0</strong> awaiting moderation")
200}
201
202// A repository that is not public with public issues has no form.
203func TestWebPublicIssueFormIsNotThere(t *testing.T) {
204	h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
205	_, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
206	require.NoError(t, err)
207	b := browser{h}
208	site := map[string]string{"Origin": publicOrigin}
209	body := url.Values{"title": {"x"}}.Encode()
210
211	missing := b.get("/nope/issues/new", nil)
212	for name, mode := range map[string]domain.IssuesMode{"off": domain.IssuesOff, "owner": domain.IssuesOwner} {
213		_, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &mode})
214		require.NoError(t, err)
215		got := b.get("/proj/issues/new", nil)
216		assert.Equal(t, 404, got.status, name)
217		assert.Equal(t, missing.body, got.body, name+": the same page as a missing repository")
218		assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, name)
219	}
220
221	pub := domain.IssuesPublic
222	_, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
223	require.NoError(t, err)
224	priv := domain.VisibilityPrivate
225	_, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
226	require.NoError(t, err)
227	assert.Equal(t, 404, b.get("/proj/issues/new", nil).status, "private")
228	assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, "private")
229}
230
231func decodeB64URL(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }

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 43

↑↓ moveenter openesc close