| @@ -0,0 +1,332 @@ |
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + | |
| 3 | + | package web |
| 4 | + | |
| 5 | + | import ( |
| 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 | + | |
| 22 | + | func 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 | + | |
| 29 | + | func 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 | + | |
| 41 | + | type 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. |
| 50 | + | func 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 | + | |
| 68 | + | func (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 | + | |
| 88 | + | func 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="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 | + | |
| 119 | + | func 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 | + | |
| 151 | + | func 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 | + | |
| 163 | + | func 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="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 | + | |
| 196 | + | func 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 | + | |
| 206 | + | func 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 | + | |
| 235 | + | func 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 | + | |
| 248 | + | func 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="i"`) |
| 261 | + | } |
| 262 | + | |
| 263 | + | func 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 | + | |
| 286 | + | func 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 | + | |
| 295 | + | func 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.IssueClosed{Issue: issue}, "mrs", "", ""}, |
| 313 | + | {domain.IssueClosed{Issue: issue}, "summary", "", ""}, |
| 314 | + | } |
| 315 | + | for _, c := range cases { |
| 316 | + | ev := ports.WatchEvent{Event: c.ev} |
| 317 | + | assert.True(t, liveEvent(ev), c.ev.EventKind()) |
| 318 | + | got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev) |
| 319 | + | require.NoError(t, err) |
| 320 | + | if c.want == "" { |
| 321 | + | assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view) |
| 322 | + | continue |
| 323 | + | } |
| 324 | + | require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view) |
| 325 | + | assert.Equal(t, "mr-notice", got[0].event) |
| 326 | + | assert.Contains(t, got[0].html, c.want) |
| 327 | + | } |
| 328 | + | // MR events do not touch the issue pages. |
| 329 | + | got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}}) |
| 330 | + | require.NoError(t, err) |
| 331 | + | assert.Empty(t, got) |
| 332 | + | } |