v0.28.1 cc7ba64

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, 5)
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-mrs"`)
188	assert.Contains(t, frags[4].html, `id="inbox-issues"`)
189
190	none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
191	require.NoError(t, err)
192	assert.Empty(t, none, "a push is not inbox news")
193}
194
195// withOwner is a context as the session middleware leaves it for the owner.
196func withOwner(ctx context.Context) context.Context {
197	return domain.ContextWithPrincipal(context.WithValue(ctx, signedInKey{}, struct{}{}), domain.Owner())
198}
199
200// A bearer token is not a web session: the inbox answers it as it answers
201// anyone else, with the unknown-page and unknown-topic responses.
202func TestInboxRefusesABearerToken(t *testing.T) {
203	t.Parallel()
204	f := &watchProbe{release: make(chan struct{})}
205	svc := mocks.NewInboxService(t) // never asked
206	srv := newTestServer(t, Config{
207		Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
208		Heartbeat: time.Hour, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
209	})
210	bearer := map[string]string{"Authorization": "Bearer tok"}
211
212	// The page.
213	missing := inboxGet(srv, "/-/nothing-here", false)
214	req := httptest.NewRequest(http.MethodGet, "/-/inbox", nil)
215	req.Header.Set("Authorization", "Bearer tok")
216	rec := httptest.NewRecorder()
217	srv.ServeHTTP(rec, req)
218	assert.Equal(t, missing.Code, rec.Code)
219	assert.Equal(t, http.StatusNotFound, rec.Code)
220	assert.Equal(t, missing.Body.String(), rec.Body.String(), "the unknown-page response")
221
222	// The stream.
223	ts := httptest.NewServer(srv)
224	t.Cleanup(ts.Close)
225	unknown, _ := open(t, ts, "topic=nope&view=inbox", nil)
226	want, _ := io.ReadAll(unknown.Body)
227	resp, _ := open(t, ts, "topic=owner&view=inbox", bearer)
228	body, _ := io.ReadAll(resp.Body)
229	assert.Equal(t, unknown.StatusCode, resp.StatusCode)
230	assert.Equal(t, string(want), string(body), "the unknown-topic response")
231	assert.Zero(t, f.opened.Load(), "no watch is opened")
232}
233
234// The owner's inbox stream ends once the session is invalid: the next event is
235// not sent.
236func TestInboxStreamEndsWhenTheSessionEnds(t *testing.T) {
237	t.Parallel()
238	f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)}
239	sess, ended := newRevocable(t, "tok")
240	svc := mocks.NewInboxService(t)
241	svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(inboxOverview(t), nil).Maybe()
242	srv := newTestServer(t, Config{
243		Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t), Events: f.service(t), Inbox: svc,
244		Heartbeat: time.Hour, Sessions: sess, Limiter: &countingLimiter{},
245	})
246	ts := httptest.NewServer(srv)
247	t.Cleanup(ts.Close)
248	resp, cancel := open(t, ts, "topic=owner&view=inbox", map[string]string{"Cookie": CookieName + "=tok"})
249	require.Equal(t, http.StatusOK, resp.StatusCode)
250	defer time.AfterFunc(5*time.Second, cancel).Stop()
251	rd := bufio.NewReader(resp.Body)
252	line := func() string {
253		s, err := rd.ReadString('\n')
254		if err != nil {
255			return "EOF"
256		}
257		return strings.TrimRight(s, "\n")
258	}
259	require.Equal(t, ": connected", line())
260	line()
261	ev := func(id string) ports.WatchEvent {
262		return ports.WatchEvent{ID: id, Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}}
263	}
264	f.push <- ev("b-1")
265	var seen []string
266	for l := line(); !strings.HasPrefix(l, "data:"); l = line() {
267		seen = append(seen, l)
268		require.NotEqual(t, "EOF", l)
269	}
270	assert.Contains(t, seen, "id: b-1")
271
272	ended.Store(true)
273	select {
274	case f.push <- ev("b-2"):
275	case <-time.After(5 * time.Second):
276		t.Fatal("the event was not delivered to the web")
277	}
278	rest, _ := io.ReadAll(rd)
279	assert.NotContains(t, string(rest), "b-2", "nothing is sent after the session ended")
280	require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the watch is closed")
281}
282
283// A repository event refreshes the inbox: issues turned off or a deletion
284// remove its items from the fragments.
285func TestInboxRefreshesOnRepoEvents(t *testing.T) {
286	t.Parallel()
287	for name, ev := range map[string]domain.Event{
288		"issues turned off": domain.RepoUpdated{Repo: repoName(t, "beta")},
289		"repo deleted":      domain.RepoDeleted{Repo: repoName(t, "beta")},
290	} {
291		t.Run(name, func(t *testing.T) {
292			t.Parallel()
293			after := ports.InboxOverview{Repos: []domain.RepoName{repoName(t, "alpha")}}
294			svc := mocks.NewInboxService(t)
295			svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(after, nil)
296			srv := inboxServer(t, svc)
297			ls := &liveStream{s: srv, view: "inbox"}
298			frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
299			require.NoError(t, err)
300			require.Len(t, frags, 5)
301			all := ""
302			for _, f := range frags {
303				all += f.html
304			}
305			assert.Contains(t, all, `id="inbox-issues"`)
306			assert.NotContains(t, all, "crash on start", "the issue is gone")
307			assert.NotContains(t, all, "/beta", "the repository is gone from the filter")
308		})
309	}
310}

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 2

Tags 45

↑↓ moveenter openesc close