v0.31.0 d5e0505

session_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"net/http"
9	"net/http/httptest"
10	"net/url"
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
24// nonceA is the one valid login link of the mocks.
25var nonceA = strings.Repeat("a", 43)
26
27// newVerifier is a WebSessionService that accepts the session token valid, checked
28// by verify when it is not nil, and that does not know any login link. A
29// call that is not expected fails the test.
30func newVerifier(t *testing.T, valid string, verify func(token string) (bool, error)) *mocks.WebSessionService {
31	t.Helper()
32	if verify == nil {
33		verify = func(token string) (bool, error) { return token == valid, nil }
34	}
35	m := mocks.NewWebSessionService(t)
36	m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn(
37		func(_ context.Context, token string) (domain.Principal, bool, error) {
38			ok, err := verify(token)
39			if !ok || err != nil {
40				return domain.Anonymous(), false, err
41			}
42			return domain.Owner(), true, nil
43		}).Maybe()
44	return m
45}
46
47// expectLogin makes nonceA the one link of m, answering with the token valid.
48func expectLogin(m *mocks.WebSessionService, valid string) {
49	m.EXPECT().LoginLinkValid(mock.Anything).RunAndReturn(func(n string) bool { return n == nonceA }).Maybe()
50	m.EXPECT().Login(mock.Anything, nonceA).Return(valid, time.Hour, nil).Maybe()
51	m.EXPECT().Login(mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Maybe()
52}
53
54// newSessions is a WebSessionService with one session token and one login link.
55func newSessions(t *testing.T, valid string) *mocks.WebSessionService {
56	t.Helper()
57	m := newVerifier(t, valid, nil)
58	expectLogin(m, valid)
59	return m
60}
61
62// newBrokenSessions is a WebSessionService whose check cannot be made.
63func newBrokenSessions(t *testing.T, valid string) *mocks.WebSessionService {
64	t.Helper()
65	m := newVerifier(t, valid, func(string) (bool, error) { return false, errors.New("kv down") })
66	expectLogin(m, valid)
67	return m
68}
69
70type countingLimiter struct{ fails int }
71
72func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 }
73func (l *countingLimiter) Fail(context.Context, string)                        { l.fails++ }
74
75func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) {
76	t.Helper()
77	lim := &countingLimiter{}
78	srv := newTestServer(t, Config{
79		Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
80		PublicURL: publicURL, Sessions: sess, Limiter: lim,
81	})
82	return srv, lim
83}
84
85func TestOriginOf(t *testing.T) {
86	t.Parallel()
87	for in, want := range map[string]string{
88		"https://forge.example":         "https://forge.example",
89		"https://Forge.Example/":        "https://forge.example",
90		"https://forge.example:443":     "https://forge.example",
91		"http://forge.example:80":       "http://forge.example",
92		"http://forge.example:8080":     "http://forge.example:8080",
93		"https://forge.example:8443":    "https://forge.example:8443",
94		"http://127.0.0.1:3000":         "http://127.0.0.1:3000",
95		"http://[::1]:3000":             "http://[::1]:3000",
96		"https://forge.example/sub/dir": "https://forge.example",
97	} {
98		u, err := url.Parse(in)
99		require.NoError(t, err)
100		assert.Equal(t, want, originOf(u), in)
101	}
102}
103
104func TestValidNonce(t *testing.T) {
105	t.Parallel()
106	assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a"))
107	for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} {
108		assert.False(t, validNonce(n), n)
109	}
110}
111
112// The gate: a POST needs the Origin of the public URL, whatever Host says, and
113// a session unless it is the login.
114func TestGate(t *testing.T) {
115	t.Parallel()
116	srv, lim := newSessionServer(t, newSessions(t, "tok"), "https://forge.example")
117	do := func(path, origin, cookie string) int {
118		req := httptest.NewRequest(http.MethodPost, path, nil)
119		req.Host = "forge.example"
120		if origin != "" {
121			req.Header.Set("Origin", origin)
122		}
123		if cookie != "" {
124			req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
125		}
126		rec := httptest.NewRecorder()
127		srv.ServeHTTP(rec, req)
128		return rec.Code
129	}
130	link := "/-/login/" + strings.Repeat("a", 43)
131
132	assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin")
133	assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok"))
134	assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok"))
135	assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme")
136	assert.Equal(t, 403, do("/-/logout", "null", "tok"))
137	assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session")
138	assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session")
139	assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok"))
140	assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated")
141
142	assert.Equal(t, 403, do(link, "", ""), "login without Origin")
143	assert.Equal(t, 403, do(link, "https://evil.example", ""))
144	assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")
145	assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))
146	assert.Equal(t, 1, lim.fails, "one failed login counted")
147	assert.Equal(t, 403, do("/-/login/x", "https://forge.example", ""), "a malformed link is not a login")
148}
149
150// GO-001: the exemption of the login POST is decided on the escaped path, the
151// one the router matches, and on exactly loginPrefix + nonce. An encoded slash
152// must not turn another route into a "login".
153func TestGateLoginExemptionIsExact(t *testing.T) {
154	t.Parallel()
155	sess := newVerifier(t, "tok", nil) // no Login is expected until the last request
156	lim := &countingLimiter{}
157	srv := newTestServer(t, Config{
158		Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
159		MergeRequests: mocks.NewMergeRequestService(t),
160		Sessions:      sess, Limiter: lim,
161	})
162	post := func(target, cookie string) int {
163		req := httptest.NewRequest(http.MethodPost, target, nil)
164		req.Header.Set("Origin", "https://forge.example")
165		if cookie != "" {
166			req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
167		}
168		rec := httptest.NewRecorder()
169		srv.ServeHTTP(rec, req)
170		return rec.Code
171	}
172	nonce := nonceA
173	for _, target := range []string{
174		"/-%2Flogin%2Fx/merge-requests/1/close",
175		"/-%2flogin%2fx/merge-requests/1/close",
176		"/-%2Flogin%2F" + nonce + "/merge-requests/1/close",
177		"/-/login%2F" + nonce,
178		"/-/login/" + nonce + "/merge-requests/1/close",
179		"/-/login/" + nonce + "/",
180		"/-/login/" + nonce + "%2Fx",
181		"/-/login/" + nonce + "%2F..%2Fx",
182		"/-/login/%2e%2e/x/merge-requests/1/close",
183		"/-/login/x/../../x/merge-requests/1/close",
184		"/-/login/",
185		"/-/login/%61" + nonce[1:],
186		"/-/login//" + nonce,
187		"/%2D/login/" + nonce,
188	} {
189		assert.Equal(t, http.StatusForbidden, post(target, ""), target)
190	}
191	// No Login was expected so far: the mock fails the test on any call.
192	sess.AssertNotCalled(t, "Login", mock.Anything, mock.Anything)
193
194	sess.EXPECT().Login(mock.Anything, nonce).Return("tok", time.Hour, nil).Once()
195	assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in")
196}
197
198// A session check that cannot be made fails closed (anonymous) and keeps the
199// cookie; a check that says no clears it.
200func TestWithSessionFailsClosed(t *testing.T) {
201	t.Parallel()
202	broken := newBrokenSessions(t, "tok")
203	srv, _ := newSessionServer(t, broken, "https://forge.example")
204	req := httptest.NewRequest(http.MethodGet, "/", nil)
205	req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
206	rec := httptest.NewRecorder()
207	r := srv.withSession(rec, req)
208	assert.False(t, SignedIn(r.Context()))
209	assert.Equal(t, domain.Anonymous(), principal(r.Context()))
210	assert.Empty(t, rec.Header().Values("Set-Cookie"))
211
212	srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example")
213	req = httptest.NewRequest(http.MethodGet, "/", nil)
214	req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
215	rec = httptest.NewRecorder()
216	r = srv.withSession(rec, req)
217	assert.False(t, SignedIn(r.Context()))
218	assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
219
220	req = httptest.NewRequest(http.MethodGet, "/", nil)
221	req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
222	r = srv.withSession(httptest.NewRecorder(), req)
223	assert.True(t, SignedIn(r.Context()))
224	assert.Equal(t, domain.Owner(), principal(r.Context()), "a valid session is the owner")
225	assert.Equal(t, domain.Anonymous(), principal(context.Background()), "no principal is anonymous")
226}
227
228// GO-006: the answer to a request with an invalid cookie is not cached and
229// varies on Cookie, and nothing later in the request undoes it: the page
230// writer, the raw file handler and the event stream keep it.
231func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
232	t.Parallel()
233	sess := newSessions(t, "tok")
234	srv, _ := newSessionServer(t, sess, "https://forge.example")
235
236	// withSession alone.
237	req := httptest.NewRequest(http.MethodGet, "/", nil)
238	req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
239	rec := httptest.NewRecorder()
240	srv.withSession(rec, req)
241	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
242	assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"))
243	assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
244
245	// A check that cannot be made keeps the cookie and the headers as they are.
246	broken, _ := newSessionServer(t, newBrokenSessions(t, "tok"), "https://forge.example")
247	rec = httptest.NewRecorder()
248	broken.withSession(rec, req)
249	assert.Empty(t, rec.Header().Values("Cache-Control"))
250	assert.Empty(t, rec.Header().Values("Vary"))
251
252	// No cookie, a valid cookie: untouched by withSession.
253	rec = httptest.NewRecorder()
254	srv.withSession(rec, httptest.NewRequest(http.MethodGet, "/", nil))
255	assert.Empty(t, rec.Header().Values("Cache-Control"))
256	assert.Empty(t, rec.Header().Values("Vary"))
257
258	// The page writer, a 404 and a 500 included, does not replace it.
259	for _, target := range []string{"/", "/nope/refs", "/x/tree/main"} {
260		req := httptest.NewRequest(http.MethodGet, target, nil)
261		req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
262		rec := httptest.NewRecorder()
263		srv.ServeHTTP(rec, req)
264		assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target)
265		assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target)
266		assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)
267	}
268
269	// The shared helper keeps no-store for a visitor, and replaces it for the
270	// owner (whose answer is private anyway).
271	h := http.Header{"Cache-Control": {"no-store"}, "Vary": {"Accept, cookie"}}
272	cacheHeaders(context.Background(), h, "no-cache", sessionCache)
273	assert.Equal(t, []string{"no-store"}, h.Values("Cache-Control"))
274	assert.Equal(t, []string{"Accept, cookie"}, h.Values("Vary"), "no second Vary: Cookie")
275	owner := context.WithValue(context.Background(), signedInKey{}, struct{}{})
276	cacheHeaders(owner, h, "no-cache", sessionCache)
277	assert.Equal(t, []string{sessionCache}, h.Values("Cache-Control"))
278
279	// Static assets are not touched by sessions at all, cookie or not.
280	req = httptest.NewRequest(http.MethodGet, StaticPrefix+"nope", nil)
281	req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
282	rec = httptest.NewRecorder()
283	srv.ServeHTTP(rec, req)
284	assert.Empty(t, rec.Header().Values("Set-Cookie"))
285	assert.Empty(t, rec.Header().Values("Vary"))
286}
287
288// Without sessions configured there is no login route and no cookie is read.
289func TestNoSessionsNoRoutes(t *testing.T) {
290	t.Parallel()
291	srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)})
292	rec := httptest.NewRecorder()
293	srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
294	assert.Equal(t, 404, rec.Code)
295
296	_, err := New(Config{
297		Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
298		PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t),
299	})
300	assert.Error(t, err, "sessions need a limiter")
301}
302
303func TestLogPathHidesLinks(t *testing.T) {
304	t.Parallel()
305	assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
306	assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
307	for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} {
308		assert.Equal(t, "/-/login/…", logPath(p), p)
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 4

Tags 45

↑↓ moveenter openesc close