main 1fb2421

issues_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"net/http"
7	"net/http/httptest"
8	"net/url"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20)
21
22func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23	t.Helper()
24	v := publicRepo(t, "proj")
25	v.Settings.Issues = mode
26	return v
27}
28
29func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30	t.Helper()
31	ti, err := domain.ParseIssueTitle(title)
32	require.NoError(t, err)
33	b, err := domain.ParseIssueBody(body)
34	require.NoError(t, err)
35	return domain.Issue{
36		Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37		CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38	}
39}
40
41type issueRig struct {
42	srv    *Server
43	issues *mocks.IssueService
44	nums   *mocks.IssueNumberResolver
45	mrs    *mocks.MergeRequestService
46}
47
48// newIssueRig serves proj with the given mode, signed in as the owner when
49// the cookie "tok" is sent.
50func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51	t.Helper()
52	r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53	browse := mocks.NewBrowseService(t)
54	browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55	r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56	cfg := Config{
57		Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58		Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59	}
60	if withNumbers {
61		r.nums = mocks.NewIssueNumberResolver(t)
62		cfg.IssueNumbers = r.nums
63	}
64	r.srv = newTestServer(t, cfg)
65	return r
66}
67
68func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69	var body *strings.Reader
70	if form != nil {
71		body = strings.NewReader(form.Encode())
72	} else {
73		body = strings.NewReader("")
74	}
75	req := httptest.NewRequest(method, path, body)
76	if method == http.MethodPost {
77		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78		req.Header.Set("Origin", testPublicURL)
79	}
80	if owner {
81		req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82	}
83	rec := httptest.NewRecorder()
84	r.srv.ServeHTTP(rec, req)
85	return rec
86}
87
88func TestIssueList(t *testing.T) {
89	t.Parallel()
90	r := newIssueRig(t, domain.IssuesOwner, false)
91	r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92		Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93	r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94		Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95
96	rec := r.do("GET", "/proj/issues", false, nil)
97	require.Equal(t, 200, rec.Code, rec.Body.String())
98	body := rec.Body.String()
99	assert.Contains(t, body, `href="/proj/issues/3"`)
100	assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101	assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102	assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103	assert.Contains(t, body, `href="/proj/issues" data-key="g i"`, "the Issues tab")
104	assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105	assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106
107	closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108	require.Equal(t, 200, closed.Code)
109	assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110
111	assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112
113	owner := r.do("GET", "/proj/issues", true, nil)
114	require.Equal(t, 200, owner.Code)
115	assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116	assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117}
118
119func TestIssueShow(t *testing.T) {
120	t.Parallel()
121	r := newIssueRig(t, domain.IssuesPublic, false)
122	c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123	c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124	r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125		Issue:    testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126		Comments: []domain.IssueComment{c},
127	}, nil)
128
129	anon := r.do("GET", "/proj/issues/5", false, nil)
130	require.Equal(t, 200, anon.Code, anon.Body.String())
131	body := anon.Body.String()
132	assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133	assert.NotContains(t, body, "<script>alert")
134	assert.Contains(t, body, `id="c1"`)
135	assert.Contains(t, body, `href="https://example.org"`)
136	assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137	assert.NotContains(t, body, `class="cform"`)
138	assert.NotContains(t, body, "/proj/issues/5/close")
139	assert.Contains(t, body, `view=issue`)
140
141	owner := r.do("GET", "/proj/issues/5", true, nil)
142	require.Equal(t, 200, owner.Code)
143	assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144	assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145	assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146
147	assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148	assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149}
150
151func TestIssueShowClosedOffersReopen(t *testing.T) {
152	t.Parallel()
153	r := newIssueRig(t, domain.IssuesOwner, false)
154	r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155		Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156	}, nil)
157	owner := r.do("GET", "/proj/issues/5", true, nil)
158	require.Equal(t, 200, owner.Code)
159	assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160	assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161}
162
163func TestIssueNotFound(t *testing.T) {
164	t.Parallel()
165	// Mode off: the pages and the tab are not there; the use case is not asked.
166	off := newIssueRig(t, domain.IssuesOff, false)
167	for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168		assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169	}
170	summary := off.do("GET", "/proj", false, nil)
171	require.Equal(t, 200, summary.Code)
172	assert.NotContains(t, summary.Body.String(), `data-key="g i"`)
173
174	// The mode is unset in the settings: off as well.
175	unset := newIssueRig(t, "", false)
176	assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177
178	// An unknown repository.
179	assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180
181	// A private repository, for an anonymous visitor.
182	priv := mocks.NewRepoService(t)
183	priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184	browse := mocks.NewBrowseService(t)
185	srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186	rec := httptest.NewRecorder()
187	srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188	assert.Equal(t, 404, rec.Code)
189
190	// The use case says not found for a number nobody has.
191	r := newIssueRig(t, domain.IssuesOwner, false)
192	r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193	assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194}
195
196func TestIssueNewForm(t *testing.T) {
197	t.Parallel()
198	r := newIssueRig(t, domain.IssuesOwner, false)
199	assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200	rec := r.do("GET", "/proj/issues/new", true, nil)
201	require.Equal(t, 200, rec.Code)
202	assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203	assert.Contains(t, rec.Body.String(), `name="title"`)
204}
205
206func TestIssuePosts(t *testing.T) {
207	t.Parallel()
208	r := newIssueRig(t, domain.IssuesOwner, false)
209	name := repoName(t, "proj")
210	r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211		Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212	r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213		Return(domain.IssueComment{ID: 4}, nil)
214	r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215	r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216
217	rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218	assert.Equal(t, 303, rec.Code)
219	assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221
222	rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223	assert.Equal(t, 303, rec.Code)
224	assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225
226	rec = r.do("POST", "/proj/issues/7/close", true, nil)
227	assert.Equal(t, 303, rec.Code)
228	assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229
230	rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231	assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233}
234
235func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236	t.Parallel()
237	r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238	for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239		assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240		req := httptest.NewRequest("POST", p, nil)
241		req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242		rec := httptest.NewRecorder()
243		r.srv.ServeHTTP(rec, req)
244		assert.Equal(t, 403, rec.Code, p+" without Origin")
245	}
246}
247
248func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249	t.Parallel()
250	browse := mocks.NewBrowseService(t)
251	browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252	srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253	for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254		rec := httptest.NewRecorder()
255		srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256		assert.Equal(t, 404, rec.Code, p)
257	}
258	rec := httptest.NewRecorder()
259	srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260	assert.NotContains(t, rec.Body.String(), `data-key="g i"`)
261}
262
263func TestIssueNumberRedirects(t *testing.T) {
264	t.Parallel()
265	r := newIssueRig(t, domain.IssuesOwner, true)
266	name := repoName(t, "proj")
267	r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268	r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269	r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270	r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271	r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272
273	rec := r.do("GET", "/proj/issues/1", false, nil)
274	assert.Equal(t, 303, rec.Code)
275	assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277
278	rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279	assert.Equal(t, 303, rec.Code)
280	assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281
282	assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283	assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284}
285
286func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287	t.Parallel()
288	r := newIssueRig(t, domain.IssuesOwner, false)
289	r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290	r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291	assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292	assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293}
294
295func TestIssueLiveNotices(t *testing.T) {
296	t.Parallel()
297	r := newIssueRig(t, domain.IssuesOwner, false)
298	issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299	cases := []struct {
300		ev   domain.Event
301		view string
302		n    string
303		want string // "" : nothing
304	}{
305		{domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306		{domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307		{domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308		{domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309		{domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310		{domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311		{domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312		{domain.IssueReferenced{Repo: issue.Repo, Issue: 5, MergeRequest: 2}, "issue", "5", "a merge request mentions this issue"},
313		{domain.IssueReferenced{Repo: issue.Repo, Issue: 6, MergeRequest: 2}, "issue", "5", ""},
314		{domain.IssueClosed{Issue: issue}, "mrs", "", ""},
315		{domain.IssueClosed{Issue: issue}, "summary", "", ""},
316	}
317	for _, c := range cases {
318		ev := ports.WatchEvent{Event: c.ev}
319		assert.True(t, liveEvent(ev), c.ev.EventKind())
320		got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
321		require.NoError(t, err)
322		if c.want == "" {
323			assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
324			continue
325		}
326		require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
327		assert.Equal(t, "mr-notice", got[0].event)
328		assert.Contains(t, got[0].html, c.want)
329	}
330	// MR events do not touch the issue pages.
331	got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
332	require.NoError(t, err)
333	assert.Empty(t, got)
334}
335
336func TestIssueShowReferences(t *testing.T) {
337	t.Parallel()
338	r := newIssueRig(t, domain.IssuesPublic, true)
339	title, err := domain.ParseMRTitle("fix <b>it</b>")
340	require.NoError(t, err)
341	issue := testIssue(t, 5, domain.IssueStateClosed, "bug", "see #7 and #8")
342	issue.ClosedByMergeRequest = 2
343	r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
344		Issue: issue,
345		References: []ports.IssueReference{
346			{Number: 2, Title: title, State: domain.MRStateMerged, Closes: true},
347			{Number: 3, Title: title, State: domain.MRStateOpen},
348		},
349	}, nil)
350	r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(7)).Return(domain.NumberMergeRequest, nil)
351	r.nums.EXPECT().Kind(mock.Anything, mock.Anything, repoName(t, "proj"), int64(8)).Return(domain.NumberNone, nil)
352
353	rec := r.do("GET", "/proj/issues/5", false, nil)
354	require.Equal(t, 200, rec.Code, rec.Body.String())
355	body := rec.Body.String()
356	assert.Contains(t, body, "closed by")
357	assert.Contains(t, body, `href="/proj/merge-requests/2"`)
358	assert.Contains(t, body, ">!2<")
359	assert.Contains(t, body, ">!3<")
360	assert.Contains(t, body, `id="r-refs"`)
361	assert.Contains(t, body, "closes")
362	assert.Contains(t, body, "fix &lt;b&gt;it&lt;/b&gt;")
363	assert.Contains(t, body, `href="/proj/merge-requests/7"`, "#7 in the body")
364	assert.Contains(t, body, `title="merge request 7"`)
365	assert.NotContains(t, body, `/issues/8"`)
366}
367
368func TestIssueShowWithoutReferences(t *testing.T) {
369	t.Parallel()
370	r := newIssueRig(t, domain.IssuesPublic, false)
371	r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
372		Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", ""),
373	}, nil)
374	body := r.do("GET", "/proj/issues/5", false, nil).Body.String()
375	assert.NotContains(t, body, "r-refs")
376	assert.NotContains(t, body, "closed by")
377}

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 4

Tags 45

↑↓ moveenter openesc close