1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bufio"
7 "context"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21 "bnema.dev/ermite/internal/ports/mocks"
22)
23
24func inboxOverview(t testing.TB) ports.InboxOverview {
25 t.Helper()
26 mrTitle, err := domain.ParseMRTitle("fix <b>search</b>")
27 require.NoError(t, err)
28 isTitle, err := domain.ParseIssueTitle("crash on start")
29 require.NoError(t, err)
30 at := time.Now().Add(-2 * time.Hour)
31 ov := ports.InboxOverview{
32 MergeRequests: []domain.MergeRequest{{
33 Repo: repoName(t, "alpha"), Number: 4, Title: mrTitle, State: domain.MRStateOpen, UpdatedAt: at,
34 }},
35 Issues: []domain.Issue{{Repo: repoName(t, "beta"), Number: 7, Title: isTitle, State: domain.IssueStateOpen, UpdatedAt: at}},
36 Repos: []domain.RepoName{repoName(t, "alpha"), repoName(t, "beta")},
37 }
38 ov.Counts = ports.InboxCounts{MergeRequests: 1, Issues: 1}
39 return ov
40}
41
42func inboxServer(t *testing.T, inbox ports.InboxService) *Server {
43 t.Helper()
44 return newTestServer(t, Config{
45 Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox,
46 Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
47 })
48}
49
50func inboxGet(srv *Server, path string, owner bool) *httptest.ResponseRecorder {
51 req := httptest.NewRequest(http.MethodGet, path, nil)
52 if owner {
53 req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
54 }
55 rec := httptest.NewRecorder()
56 srv.ServeHTTP(rec, req)
57 return rec
58}
59
60func TestInboxPageForTheOwner(t *testing.T) {
61 t.Parallel()
62 svc := mocks.NewInboxService(t)
63 svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
64 srv := inboxServer(t, svc)
65
66 rec := inboxGet(srv, "/-/inbox", true)
67 require.Equal(t, 200, rec.Code, rec.Body.String())
68 body := rec.Body.String()
69 assert.Contains(t, body, "<strong>1</strong> merge requests · <strong>1</strong> issues")
70 assert.Contains(t, body, "Open merge requests")
71 assert.Contains(t, body, "Open issues")
72 assert.Contains(t, body, `href="/alpha/merge-requests/4"`)
73 assert.Contains(t, body, "fix <b>search</b>")
74 assert.Contains(t, body, `href="/beta/issues/7"`)
75 assert.Contains(t, body, "#7")
76 assert.Contains(t, body, "2h ago")
77 assert.Contains(t, body, `href="/-/inbox?repo=beta"`, "the filter")
78 assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
79}
80
81func TestInboxPageIsMissingForEveryoneElse(t *testing.T) {
82 t.Parallel()
83 svc := mocks.NewInboxService(t) // never asked
84 srv := inboxServer(t, svc)
85 missing := inboxGet(srv, "/-/nothing-here", false)
86 require.Equal(t, 404, missing.Code)
87
88 anon := inboxGet(srv, "/-/inbox", false)
89 assert.Equal(t, 404, anon.Code)
90 assert.Equal(t, missing.Body.String(), anon.Body.String(), "the same page as an unknown one")
91 assert.NotContains(t, anon.Body.String(), "Open merge requests")
92
93 // A bad cookie is anonymous as well.
94 req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
95 req.AddCookie(&http.Cookie{Name: CookieName, Value: "other"})
96 rec := httptest.NewRecorder()
97 srv.ServeHTTP(rec, req)
98 assert.Equal(t, 404, rec.Code)
99}
100
101func TestInboxNotMountedWithoutTheService(t *testing.T) {
102 t.Parallel()
103 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}})
104 assert.Equal(t, 404, inboxGet(srv, "/-/inbox", true).Code)
105}
106
107func TestInboxFilterAndEmptyStates(t *testing.T) {
108 t.Parallel()
109 svc := mocks.NewInboxService(t)
110 ov := inboxOverview(t)
111 filtered := ov
112 filtered.MergeRequests, filtered.Issues = nil, nil
113 svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "gamma")).Return(filtered, nil)
114 srv := inboxServer(t, svc)
115
116 rec := inboxGet(srv, "/-/inbox?repo=gamma", true)
117 require.Equal(t, 200, rec.Code)
118 body := rec.Body.String()
119 assert.Contains(t, body, "no open merge requests.")
120 assert.Contains(t, body, "no open issues.")
121 assert.Contains(t, body, `href="/-/inbox"`, "the link back to all")
122
123 assert.Equal(t, 404, inboxGet(srv, "/-/inbox?repo=Bad%20Name", true).Code, "an invalid name is a missing page")
124}
125
126func TestInboxButtonOnHomeIsForTheOwnerOnly(t *testing.T) {
127 t.Parallel()
128 svc := mocks.NewInboxService(t)
129 ov := inboxOverview(t)
130 ov.Counts = ports.InboxCounts{MergeRequests: 3, Issues: 2}
131 svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil).Once() // the owner's home only
132 srv := inboxServer(t, svc)
133
134 anon := inboxGet(srv, "/", false)
135 require.Equal(t, 200, anon.Code)
136 assert.NotContains(t, anon.Body.String(), "/-/inbox")
137 assert.NotContains(t, anon.Body.String(), "Inbox")
138
139 owner := inboxGet(srv, "/", true)
140 require.Equal(t, 200, owner.Code)
141 assert.Contains(t, owner.Body.String(), `href="/-/inbox"`)
142 assert.Contains(t, owner.Body.String(), `<span class="count">5</span>`)
143}
144
145func TestInboxStreamIsTheOwnersAlone(t *testing.T) {
146 t.Parallel()
147 f := &watchProbe{release: make(chan struct{})}
148 svc := mocks.NewInboxService(t)
149 srv := newTestServer(t, Config{
150 Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
151 Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
152 })
153 ts := httptest.NewServer(srv)
154 t.Cleanup(ts.Close)
155
156 unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
157 want, _ := io.ReadAll(unknown.Body)
158 for name, hdr := range map[string]map[string]string{
159 "anonymous": nil,
160 "another token": {"Cookie": CookieName + "=other"},
161 } {
162 resp, _ := open(t, ts, "topic=owner&view=inbox", hdr)
163 body, _ := io.ReadAll(resp.Body)
164 assert.Equal(t, unknown.StatusCode, resp.StatusCode, name)
165 assert.Equal(t, string(want), string(body), name)
166 assert.Equal(t, unknown.Header.Get("Cache-Control"), resp.Header.Get("Cache-Control"), name)
167 }
168 assert.Zero(t, f.opened.Load(), "no watch is opened for them")
169
170 resp, _ := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
171 assert.Equal(t, http.StatusOK, resp.StatusCode)
172 assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
173}
174
175func TestInboxLiveFragments(t *testing.T) {
176 t.Parallel()
177 svc := mocks.NewInboxService(t)
178 svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil)
179 srv := inboxServer(t, svc)
180 ls := &liveStream{s: srv, view: "inbox"}
181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182 require.NoError(t, err)
183 require.Len(t, frags, 6)
184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186 assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187 assert.Contains(t, frags[3].html, `id="inbox-runs"`)
188 assert.Contains(t, frags[4].html, `id="inbox-mrs"`)
189 assert.Contains(t, frags[5].html, `id="inbox-issues"`)
190
191 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
192 require.NoError(t, err)
193 assert.Empty(t, none, "a push is not inbox news")
194}
195
196// withOwner is a context as the session middleware leaves it for the owner.
197func withOwner(ctx context.Context) context.Context {
198 return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner())
199}
200
201// A bearer token is not a web session: the inbox answers it as it answers
202// anyone else, with the unknown-page and unknown-topic responses.
203func TestInboxRefusesABearerToken(t *testing.T) {
204 t.Parallel()
205 f := &watchProbe{release: make(chan struct{})}
206 svc := mocks.NewInboxService(t) // never asked
207 srv := newTestServer(t, Config{
208 Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
209 Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
210 })
211 bearer := map[string]string{"Authorization": "Bearer tok"}
212
213 // The page.
214 missing := inboxGet(srv, "/-/nothing-here", false)
215 req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
216 req.Header.Set("Authorization", "Bearer tok")
217 rec := httptest.NewRecorder()
218 srv.ServeHTTP(rec, req)
219 assert.Equal(t, missing.Code, rec.Code)
220 assert.Equal(t, http.StatusNotFound, rec.Code)
221 assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response")
222
223 // The stream.
224 ts := httptest.NewServer(srv)
225 t.Cleanup(ts.Close)
226 unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
227 want, _ := io.ReadAll(unknown.Body)
228 resp, _ := open(t, ts, "topic=owner&view=inbox", bearer)
229 body, _ := io.ReadAll(resp.Body)
230 assert.Equal(t, unknown.StatusCode, resp.StatusCode)
231 assert.Equal(t, string(want), string(body), "the unknown-topic response")
232 assert.Zero(t, f.opened.Load(), "no watch is opened")
233}
234
235// The owner's inbox stream ends once the session is invalid: the next event is
236// not sent.
237func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) {
238 t.Parallel()
239 f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
240 sess, ended := newRevocable(t, "tok")
241 svc := mocks.NewInboxService(t)
242 svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe()
243 srv := newTestServer(t, Config{
244 Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
245 Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{},
246 })
247 ts := httptest.NewServer(srv)
248 t.Cleanup(ts.Close)
249 resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
250 require.Equal(t, http.StatusOK, resp.StatusCode)
251 defer time.AfterFunc(5*time.Second, cancel).Stop()
252 rd := bufio.NewReader(resp.Body)
253 line := func() string {
254 s, err := rd.ReadString('\n')
255 if err != nil {
256 return "EOF"
257 }
258 return strings.TrimRight(s, "\n")
259 }
260 require.Equal(t, ": connected", line())
261 line()
262 ev := func(id string) ports.WatchEvent {
263 return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}
264 }
265 f.push <- ev("b-1")
266 var seen []string
267 for l := line(); !strings.HasPrefix(l, "data:"); l = line() {
268 seen = append(seen, l)
269 require.NotEqual(t, "EOF", l)
270 }
271 assert.Contains(t, seen, "id: b-1")
272
273 ended.Store(true)
274 select {
275 case f.push <- ev("b-2"):
276 case <-time.After(5 * time.Second):
277 t.Fatal("the event was not delivered to the web")
278 }
279 rest, _ := io.ReadAll(rd)
280 assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended")
281 require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed")
282}
283
284// A repository event refreshes the inbox: issues turned off or a deletion
285// remove its items from the fragments.
286func TestInboxRefreshesOnRepoEvents(t *testing.T) {
287 t.Parallel()
288 for name, ev := range map[string]domain.Event{
289 "issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
290 "run updated": domain.RunUpdated{},
291 "repo deleted": domain.RepoDeleted{Repo: repoName(t, "beta")},
292 } {
293 t.Run(name, func(t *testing.T) {
294 t.Parallel()
295 after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}}
296 svc := mocks.NewInboxService(t)
297 svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil)
298 srv := inboxServer(t, svc)
299 ls := &liveStream{s: srv, view: "inbox"}
300 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
301 require.NoError(t, err)
302 require.Len(t, frags, 6)
303 all := ""
304 for _, f := range frags {
305 all += f.html
306 }
307 assert.Contains(t, all, `id="inbox-issues"`)
308 assert.NotContains(t, all, "crash on start", "the issue is gone")
309 assert.NotContains(t, all, "/beta", "the repository is gone from the filter")
310 })
311 }
312}