v0.35.0 e86dbd6

inbox_test.go RawHistoryFolder

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 &lt;b&gt;search&lt;/b&gt;")
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}

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