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 <b>on</b> 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, "<script>x</script>")
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 <b>it</b>")
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}