main 2aca1df

web_session_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"io"
8	"net/http"
9	"net/netip"
10	"net/url"
11	"strings"
12	"testing"
13	"time"
14
15	"connectrpc.com/connect"
16	"github.com/bnema/zerowrap"
17	"github.com/golang-jwt/jwt/v5"
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/require"
20
21	pb "bnema.dev/ermite-api/gen/ermite"
22	"bnema.dev/ermite/internal/domain"
23	"bnema.dev/ermite/internal/ports"
24)
25
26const (
27	publicOrigin  = "http://example.test" // ERMITE_PUBLIC_URL of the harness
28	sessionCookie = "__Host-ermite"
29)
30
31// page is the answer of a request to the web.
32type page struct {
33	status int
34	hdr    http.Header
35	body   string
36}
37
38func (p page) cookie(name string) *http.Cookie {
39	for _, c := range (&http.Response{Header: p.hdr}).Cookies() {
40		if c.Name == name {
41			return c
42		}
43	}
44	return nil
45}
46
47// browser is a bare HTTP client: it follows no redirect and keeps no cookie,
48// so that the tests say exactly what the browser sends.
49type browser struct {
50	h *harness
51}
52
53func (b browser) do(method, path string, hdr map[string]string, body string) page {
54	b.h.t.Helper()
55	req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body))
56	require.NoError(b.h.t, err)
57	for k, v := range hdr {
58		req.Header.Set(k, v)
59	}
60	if body != "" {
61		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
62	}
63	resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
64	require.NoError(b.h.t, err)
65	defer func() { _ = resp.Body.Close() }()
66	out, err := io.ReadAll(resp.Body)
67	require.NoError(b.h.t, err)
68	return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
69}
70
71func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") }
72
73// as is the header map of a browser holding the session token.
74func as(token string) map[string]string {
75	return map[string]string{"Cookie": sessionCookie + "=" + token}
76}
77
78func post(token string) map[string]string {
79	h := as(token)
80	h["Origin"] = publicOrigin
81	return h
82}
83
84// loginPath turns the link the CLI printed into a path on the test server.
85func loginPath(t *testing.T, link string) string {
86	t.Helper()
87	u, err := url.Parse(strings.TrimSpace(link))
88	require.NoError(t, err)
89	assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
90	assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path)
91	return u.Path
92}
93
94// signIn runs `ermite web login` and confirms the link: it returns the token
95// of the cookie the browser would hold.
96func (c *cliHarness) signIn(t *testing.T) string {
97	t.Helper()
98	res := c.cli(t, adminToken, "web", "login").ok(t)
99	p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "")
100	require.Equal(t, http.StatusSeeOther, p.status, p.body)
101	ck := p.cookie(sessionCookie)
102	require.NotNil(t, ck)
103	return ck.Value
104}
105
106// AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI
107// link to the private page and back out.
108func TestWebLoginEndToEnd(t *testing.T) {
109	c := newCLIHarness(t)
110	c.seed("secret", domain.VisibilityPrivate, "notes.txt")
111	c.seed("pub", domain.VisibilityPublic, "a.txt")
112	b := browser{c.harness}
113
114	// Anonymous: the private repository is invisible; public caches as before.
115	home := b.get("/", nil)
116	assert.Contains(t, home.body, "pub")
117	assert.NotContains(t, home.body, "secret")
118	assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
119	assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")
120	assert.NotContains(t, home.body, `action="/-/logout"`)
121	assert.NotContains(t, home.body, `>private</span>`, "an anonymous visitor sees no private badge")
122	assert.Equal(t, 404, b.get("/secret", nil).status)
123
124	// The CLI asks for a link and prints it; stderr says when it ends.
125	res := c.cli(t, adminToken, "web", "login").ok(t)
126	assert.Contains(t, res.stderr, "works once")
127	path := loginPath(t, res.stdout)
128
129	// GET shows the confirmation and never consumes the link.
130	for range 3 {
131		p := b.get(path, nil)
132		assert.Equal(t, 200, p.status)
133		assert.Equal(t, "no-store", p.hdr.Get("Cache-Control"))
134		// Only the origin leaves the page; no-referrer would make the browser
135		// send Origin: null on the sign-in POST.
136		assert.Equal(t, "strict-origin", p.hdr.Get("Referrer-Policy"))
137		assert.Contains(t, p.body, `method="post"`)
138		assert.Contains(t, p.body, `action="`+path+`"`)
139		assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session")
140		assertNoInline(t, p.body)
141	}
142	// A POST that is not from this site is refused and does not consume it.
143	assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin")
144	assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin")
145	assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin")
146	assert.Equal(t, 200, b.get(path, nil).status, "still pending")
147
148	// The POST consumes it and sets the cookie.
149	login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "")
150	require.Equal(t, http.StatusSeeOther, login.status, login.body)
151	assert.Equal(t, "/", login.hdr.Get("Location"))
152	assert.Equal(t, "no-store", login.hdr.Get("Cache-Control"))
153	ck := login.cookie(sessionCookie)
154	require.NotNil(t, ck)
155	assert.True(t, ck.Secure && ck.HttpOnly)
156	assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
157	assert.Equal(t, "/", ck.Path)
158	assert.Empty(t, ck.Domain, "no Domain: host only")
159	assert.Equal(t, 12*60*60, ck.MaxAge)
160	token := ck.Value
161	claims := jwt.MapClaims{}
162	_, _, err := jwt.NewParser().ParseUnverified(token, claims)
163	require.NoError(t, err)
164	assert.Equal(t, "owner", claims["sub"])
165	assert.Equal(t, float64(0), claims["epoch"])
166	exp, err := claims.GetExpirationTime()
167	require.NoError(t, err)
168	assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute)
169	assert.NotNil(t, claims["iat"])
170
171	// Replayed and unknown links fail with the same page.
172	for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} {
173		g := b.get(p, nil)
174		assert.Equal(t, 404, g.status, p)
175		assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))
176		assert.Contains(t, g.body, "login link not valid")
177		r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")
178		assert.Nil(t, r.cookie(sessionCookie))
179		if validNonceShape(p) {
180			assert.Equal(t, 404, r.status, p)
181			assert.Equal(t, g.body, r.body, "no detail tells why")
182		} else {
183			// Not even the shape of a link: the gate refuses it, as it does
184			// any POST without a session.
185			assert.Equal(t, 403, r.status, p)
186		}
187	}
188
189	// Signed in: private repositories show on every page, headers say private.
190	for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} {
191		got := b.get(p, as(token))
192		if p == "/nope" {
193			assert.Equal(t, 404, got.status)
194		} else {
195			assert.Equal(t, 200, got.status, p)
196		}
197		assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
198		assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
199		assertNoInline(t, got.body)
200	}
201	home = b.get("/", as(token))
202	assert.Contains(t, home.body, "secret")
203	assert.Equal(t, 1, strings.Count(home.body, `<span class="pill">private</span>`), "only the private repository has the badge")
204	assert.Regexp(t, `(?s)<li id="repo-secret".*?<span class="pill">private</span>.*?</li>\s*<li id="repo-pub"|<li id="repo-pub".*?</li>\s*<li id="repo-secret".*?<span class="pill">private</span>`, home.body)
205	assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`)
206	assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
207	raw := b.get("/secret/raw/main/notes.txt", as(token))
208	assert.Equal(t, 200, raw.status)
209	assert.Equal(t, "private, no-store", raw.hdr.Get("Cache-Control"))
210	assert.Equal(t, 404, b.get("/secret/raw/main/notes.txt", nil).status)
211	// Static assets stay public.
212	assert.Contains(t, b.get("/", as(token)).body, "/-/static/")
213
214	// The live stream of a private repository is the owner's.
215	assert.Equal(t, 404, b.get("/-/events?topic=repo:secret", nil).status)
216	sctx, stop := context.WithCancel(c.ctx)
217	defer stop()
218	sreq, err := http.NewRequestWithContext(sctx, http.MethodGet, c.harness.url("/-/events?topic=repo:secret"), nil)
219	require.NoError(t, err)
220	sreq.Header.Set("Cookie", sessionCookie+"="+token)
221	sres, err := http.DefaultClient.Do(sreq)
222	require.NoError(t, err)
223	assert.Equal(t, 200, sres.StatusCode)
224	assert.Equal(t, "private, no-store, no-transform", sres.Header.Get("Cache-Control"))
225	assert.Contains(t, sres.Header.Values("Vary"), "Cookie")
226	stop()
227	_ = sres.Body.Close()
228
229	// The browser routes ignore Authorization: the admin token shows nothing.
230	bearer := b.get("/", map[string]string{"Authorization": "Bearer " + adminToken})
231	assert.NotContains(t, bearer.body, "secret")
232	assert.Equal(t, "no-cache", bearer.hdr.Get("Cache-Control"))
233	assert.Equal(t, 404, b.get("/secret", map[string]string{"Authorization": "Bearer " + adminToken}).status)
234
235	// The API ignores the cookie: no bearer is anonymous, so nothing private
236	// and nothing that needs a caller.
237	api := c.api("", connect.WithInterceptors(cookieInterceptor(token)))
238	list, err := api.repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
239	require.NoError(t, err)
240	require.Len(t, list.Msg.GetRepos(), 1)
241	assert.Equal(t, "pub", list.Msg.GetRepos()[0].GetName())
242	_, err = api.repo.GetRepo(context.Background(), connect.NewRequest(&pb.GetRepoRequest{Repo: "secret"}))
243	assert.Equal(t, "not_found", codeName(err))
244	_, err = api.token.ListTokens(context.Background(), connect.NewRequest(&pb.ListTokensRequest{}))
245	assert.Equal(t, "unauthenticated", codeName(err))
246	_, err = api.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
247	assert.Equal(t, "unauthenticated", codeName(err))
248	_, err = api.session.SignOutEverywhere(context.Background(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
249	assert.Equal(t, "unauthenticated", codeName(err))
250
251	// Logout: a POST from this site with the session; the cookie is deleted.
252	assert.Equal(t, 403, b.do("POST", "/-/logout", as(token), "").status, "no Origin")
253	assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": "https://evil.example", "Cookie": sessionCookie + "=" + token}, "").status)
254	assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": publicOrigin}, "").status, "no session")
255	out := b.do("POST", "/-/logout", post(token), "")
256	assert.Equal(t, http.StatusSeeOther, out.status)
257	assert.Equal(t, "/", out.hdr.Get("Location"))
258	gone := out.cookie(sessionCookie)
259	require.NotNil(t, gone)
260	assert.Negative(t, gone.MaxAge)
261	assert.Empty(t, gone.Value)
262	assert.Equal(t, 404, b.get("/secret", nil).status)
263	// A GET never signs out.
264	g := b.get("/-/logout", as(token))
265	assert.Equal(t, 404, g.status, "no page")
266	assert.Nil(t, g.cookie(sessionCookie), "the session is untouched")
267	assert.Equal(t, 200, b.get("/secret", as(token)).status, "the token itself lives until logout --all or expiry")
268
269	// logout --all: the cookie is rejected and cleared; anonymous again.
270	c.cli(t, adminToken, "web", "logout", "--all").ok(t)
271	after := b.get("/secret", as(token))
272	assert.Equal(t, 404, after.status)
273	assert.Equal(t, "no-store", after.hdr.Get("Cache-Control"), "a rejected cookie makes the answer uncacheable")
274	assert.Contains(t, after.hdr.Values("Vary"), "Cookie")
275	cleared := after.cookie(sessionCookie)
276	require.NotNil(t, cleared, "a rejected cookie is cleared")
277	assert.Negative(t, cleared.MaxAge)
278	assert.NotContains(t, b.get("/", as(token)).body, "secret")
279	assert.Equal(t, 403, b.do("POST", "/-/logout", post(token), "").status, "POST needs a valid session")
280
281	// A new link gives a session of the new epoch.
282	fresh := c.signIn(t)
283	assert.Equal(t, 200, b.get("/secret", as(fresh)).status)
284	assert.Equal(t, 404, b.get("/secret", as(token)).status)
285}
286
287// validNonceShape tells whether a login path ends in 43 characters of base64url.
288func validNonceShape(p string) bool {
289	return len(strings.TrimPrefix(p, "/-/login/")) == 43
290}
291
292// tokenID finds the id of the stored token with that secret, by listing them
293// as root and matching the hash.
294func (c *cliHarness) tokenID(t *testing.T, secret string) string {
295	t.Helper()
296	all, err := c.forge.Tokens.List(c.ctx, domain.Root())
297	require.NoError(t, err)
298	for _, tok := range all {
299		if tok.Hash == domain.HashTokenSecret(secret) {
300			return tok.ID
301		}
302	}
303	t.Fatal("token not found")
304	return ""
305}
306
307// GO-003: a session opened with a link made by an expiring token ends with the
308// token, and revoking a token with owner rights signs every browser out; any
309// other token leaves the sessions alone.
310func TestWebRevokingAnOwnerTokenSignsEveryoneOut(t *testing.T) {
311	c := newCLIHarness(t)
312	c.seed("secret", domain.VisibilityPrivate, "notes.txt")
313	b := browser{c.harness}
314
315	// The cap: a link made by a token that expires in an hour gives a cookie,
316	// and a session, of an hour.
317	soon, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{
318		Label: "soon", Scopes: domain.AllScopes(), ExpiresAt: time.Now().Add(time.Hour),
319	})
320	require.NoError(t, err)
321	res := c.cli(t, soon.Secret, "web", "login").ok(t)
322	login := b.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "")
323	require.Equal(t, http.StatusSeeOther, login.status, login.body)
324	ck := login.cookie(sessionCookie)
325	require.NotNil(t, ck)
326	assert.InDelta(t, 3600, ck.MaxAge, 5, "the cookie lasts as long as the token")
327	claims := jwt.MapClaims{}
328	_, _, err = jwt.NewParser().ParseUnverified(ck.Value, claims)
329	require.NoError(t, err)
330	exp, err := claims.GetExpirationTime()
331	require.NoError(t, err)
332	assert.WithinDuration(t, soon.Token.ExpiresAt, exp.Time, 2*time.Second)
333
334	// Revoking a token that cannot own the forge keeps the sessions.
335	session := c.signIn(t)
336	partial := c.token(domain.AllScopes()[:8])
337	c.cli(t, adminToken, "token", "revoke", c.tokenID(t, partial)).ok(t)
338	assert.Equal(t, 200, b.get("/secret", as(session)).status)
339	assert.Equal(t, 200, b.get("/secret", as(ck.Value)).status)
340
341	// Revoking one that can signs everyone out, whatever token made the link.
342	res = c.cli(t, adminToken, "token", "revoke", c.tokenID(t, soon.Secret)).ok(t)
343	assert.Contains(t, res.stdout, "revoked")
344	assert.Equal(t, 404, b.get("/secret", as(session)).status)
345	assert.Equal(t, 404, b.get("/secret", as(ck.Value)).status)
346
347	// The help says so.
348	help := c.cli(t, adminToken, "token", "revoke", "--help").ok(t)
349	assert.Contains(t, help.stdout+help.stderr, "signs every browser out")
350}
351
352// cookieInterceptor makes a Connect client send the session cookie, as a
353// browser-made call would, to show the API pays no attention to it.
354func cookieInterceptor(token string) connect.Interceptor {
355	return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
356		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
357			req.Header().Set("Cookie", sessionCookie+"="+token)
358			return next(ctx, req)
359		}
360	})
361}
362
363// The cookie must be an unforgeable HS256 token for the key of the server.
364func TestWebRejectsForgedSessions(t *testing.T) {
365	c := newCLIHarness(t)
366	c.seed("secret", domain.VisibilityPrivate, "notes.txt")
367	b := browser{c.harness}
368	good := c.signIn(t)
369	require.Equal(t, 200, b.get("/secret", as(good)).status)
370
371	now := time.Now()
372	claims := jwt.MapClaims{"sub": "owner", "epoch": 0, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
373	none, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType)
374	require.NoError(t, err)
375	wrongKey, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("not the key of the server........"))
376	require.NoError(t, err)
377	wrongAlg, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("not the key of the server........"))
378	require.NoError(t, err)
379	for name, tok := range map[string]string{
380		"alg none": none, "wrong key": wrongKey, "wrong alg": wrongAlg, "garbage": "x.y.z", "truncated": good[:len(good)-2],
381	} {
382		p := b.get("/secret", as(tok))
383		assert.Equal(t, 404, p.status, name)
384		assert.Equal(t, "no-store", p.hdr.Get("Cache-Control"), name)
385		assert.Contains(t, p.hdr.Values("Vary"), "Cookie", name)
386		ck := p.cookie(sessionCookie)
387		require.NotNil(t, ck, name)
388		assert.Negative(t, ck.MaxAge, name)
389		assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name)
390
391		// The service worker revalidates on every check, but an answer that
392		// clears a cookie stays no-store.
393		sw := b.get("/-/sw.js", as(tok))
394		assert.Equal(t, 200, sw.status, name)
395		assert.Equal(t, "no-store", sw.hdr.Get("Cache-Control"), name)
396		assert.NotNil(t, sw.cookie(sessionCookie), name)
397	}
398	assert.Equal(t, "no-cache", b.get("/-/sw.js", nil).hdr.Get("Cache-Control"))
399}
400
401// Failed logins count in the limiter of failed authentications, like a bad
402// token on the API: the eleventh attempt of one client is refused, and a valid
403// link of that client is too.
404func TestWebLoginFailuresAreRateLimited(t *testing.T) {
405	h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
406	c := &cliHarness{harness: h, url: h.srv.URL}
407	b := browser{c.harness}
408	from := func(ip string) map[string]string {
409		return map[string]string{"Origin": publicOrigin, "X-Forwarded-For": ip}
410	}
411	bad := "/-/login/" + strings.Repeat("b", 43)
412	for range 10 {
413		assert.Equal(t, 404, b.do("POST", bad, from("203.0.113.9"), "").status)
414	}
415	blocked := b.do("POST", bad, from("203.0.113.9"), "")
416	assert.Equal(t, http.StatusTooManyRequests, blocked.status)
417	assert.NotEmpty(t, blocked.hdr.Get("Retry-After"))
418
419	link := c.cli(t, adminToken, "web", "login").ok(t).stdout
420	assert.Equal(t, http.StatusTooManyRequests, b.do("POST", loginPath(t, link), from("203.0.113.9"), "").status, "the budget is spent")
421	assert.Equal(t, http.StatusSeeOther, b.do("POST", loginPath(t, link), from("203.0.113.10"), "").status, "another client is not affected")
422	// The same budget as the API's: a bad token from this client is refused.
423	_, err := c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
424	require.NoError(t, err, "GetVersion needs no authentication")
425	_, err = c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
426	assert.Equal(t, "resource_exhausted", codeName(err))
427}
428
429func forwardedFor(ip string) connect.Interceptor {
430	return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
431		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
432			req.Header().Set("X-Forwarded-For", ip)
433			return next(ctx, req)
434		}
435	})
436}
437
438// Only the owner creates links: the admin token, or a token with every scope
439// and no repository list.
440func TestSessionServiceIsOwnerOnly(t *testing.T) {
441	c := newCLIHarness(t)
442	ctx := context.Background()
443	c.seed("a", domain.VisibilityPublic)
444	partial := c.token(domain.AllScopes()[:8])
445	limited := c.token(domain.AllScopes(), "a")
446	full := c.token(domain.AllScopes())
447
448	for name, tc := range map[string]struct{ secret, code string }{
449		"anonymous":     {"", "unauthenticated"},
450		"bad token":     {"nope", "unauthenticated"},
451		"missing scope": {partial, "permission_denied"},
452		"repo list":     {limited, "permission_denied"},
453		"every scope":   {full, "ok"},
454		"admin token":   {adminToken, "ok"},
455	} {
456		a := c.api(tc.secret)
457		_, err := a.session.CreateLoginLink(ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
458		assert.Equal(t, tc.code, codeName(err), name)
459		_, err = a.session.SignOutEverywhere(ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
460		if tc.code == "unauthenticated" {
461			assert.Equal(t, tc.code, codeName(err), name)
462		} else {
463			assert.Equal(t, tc.code, codeName(err), name)
464		}
465	}
466
467	res := c.cli(t, partial, "web", "login")
468	require.Error(t, res.err)
469	assert.Contains(t, res.err.Error(), "permission denied")
470	assert.Empty(t, res.stdout)
471	res = c.cli(t, adminToken, "web", "logout")
472	require.Error(t, res.err, "--all is required")
473	res = c.cli(t, adminToken, "web", "login", "--json").ok(t)
474	assert.ElementsMatch(t, []string{"expires_at", "url"}, keys(t, res.stdout))
475}
476
477// Login links are bounded in number.
478func TestLoginLinksAreBoundedOverTheAPI(t *testing.T) {
479	c := newCLIHarness(t)
480	a := c.api(adminToken)
481	var code string
482	for range 20 {
483		_, err := a.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
484		if code = codeName(err); code != "ok" {
485			break
486		}
487	}
488	assert.Equal(t, "resource_exhausted", code)
489}
490
491// A link, a session token and the signing key are secrets: no log line holds
492// them, at the most verbose level.
493func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) {
494	c := newCLIHarness(t)
495	c.seed("secret", domain.VisibilityPrivate, "notes.txt")
496	b := browser{c.harness}
497
498	link := strings.TrimSpace(c.cli(t, adminToken, "web", "login").ok(t).stdout)
499	nonce := link[strings.LastIndex(link, "/")+1:]
500	require.Len(t, nonce, 43)
501	get := b.get(loginPath(t, link), nil)
502	require.Equal(t, 200, get.status)
503	p := b.do("POST", loginPath(t, link), map[string]string{"Origin": publicOrigin}, "")
504	token := p.cookie(sessionCookie).Value
505	b.get("/secret", as(token))
506	b.get("/-/login/"+strings.Repeat("z", 43), nil)
507	b.do("POST", "/-/login/"+strings.Repeat("z", 43), map[string]string{"Origin": publicOrigin}, "")
508
509	logs := c.logs.String()
510	assert.NotContains(t, logs, nonce)
511	assert.NotContains(t, logs, token)
512	assert.NotContains(t, logs, strings.Repeat("z", 43))
513	assert.Contains(t, logs, "login link created")
514	assert.Contains(t, logs, "owner signed in on the web")
515	key, err := c.forge.store.Sessions().Key(context.Background())
516	require.NoError(t, err)
517	assert.NotContains(t, logs, string(key))
518}
519
520// The owner principal is what the use cases see from a signed-in page, and is
521// labelled "owner": the use case logs it with a write the page made.
522func TestWebOwnerIsLabelledInLogs(t *testing.T) {
523	r := newMRRepo(t, domain.VisibilityPublic)
524	token := r.signIn(t)
525	r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
526	b := browser{r.harness}
527	res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"from the page"}})
528	require.Equal(t, http.StatusSeeOther, res.status, res.body)
529	var line string
530	for _, l := range strings.Split(r.logs.String(), "\n") {
531		if strings.Contains(l, `"comment added"`) {
532			line = l
533		}
534	}
535	assert.Contains(t, line, `"principal":"owner"`)
536}
537
538// GO-004: a Secure cookie is kept by browsers only from https (or a loopback
539// host), so an http public URL on another host cannot sign anyone in.
540func TestSessionsNeedHTTPS(t *testing.T) {
541	for in, want := range map[string]bool{
542		"http://forge.example":        true,
543		"http://forge.example:8080":   true,
544		"HTTP://Forge.Example":        true,
545		"http://10.0.0.5:3000":        true,
546		"http://192.168.1.2":          true,
547		"http://[2001:db8::1]:3000":   true,
548		"http://localhost.example":    true,
549		"http://127.0.0.1.example":    true,
550		"http://example.test":         true,
551		"https://forge.example":       false,
552		"https://localhost":           false,
553		"http://localhost":            false,
554		"http://LOCALHOST:3000":       false,
555		"http://127.0.0.1:3000":       false,
556		"http://127.1.2.3":            false,
557		"http://[::1]:3000":           false,
558		"http://[::ffff:127.0.0.1]:1": false,
559		"":                            false,
560	} {
561		assert.Equal(t, want, sessionsNeedHTTPS(in), in)
562	}
563}
564
565// The warning is logged once at startup, with the harness's http://example.test
566// as public URL, and the server starts all the same.
567func TestStartupWarnsWhenSignInCannotWork(t *testing.T) {
568	c := newCLIHarness(t)
569	logs := c.logs.String()
570	assert.Contains(t, logs, "browser sign-in cannot work")
571	assert.Contains(t, logs, `"level":"warn"`)
572
573	var buf syncBuffer
574	ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf}))
575	warnSessionsNeedHTTPS(ctx, "https://forge.example")
576	warnSessionsNeedHTTPS(ctx, "http://127.0.0.1:3000")
577	assert.Empty(t, buf.String(), "no warning for https or loopback")
578	warnSessionsNeedHTTPS(ctx, "http://forge.example")
579	assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL")
580}
581
582func TestWarnSharedClientKey(t *testing.T) {
583	t.Parallel()
584	proxies := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
585	tests := []struct {
586		name    string
587		trusted []netip.Prefix
588		cap     int
589		warns   bool
590	}{
591		{"no proxy, cap active", nil, 4, true},
592		{"proxies trusted", proxies, 4, false},
593		{"no proxy, no cap", nil, 0, false},
594	}
595	for _, tt := range tests {
596		t.Run(tt.name, func(t *testing.T) {
597			t.Parallel()
598			var buf syncBuffer
599			ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf}))
600			warnSharedClientKey(ctx, tt.trusted, tt.cap)
601			if !tt.warns {
602				assert.Empty(t, buf.String())
603				return
604			}
605			assert.Contains(t, buf.String(), `"level":"warn"`)
606			assert.Contains(t, buf.String(), "ERMITE_TRUSTED_PROXIES")
607		})
608	}
609}
610
611// AC4: passkeys exist only where WebAuthn can work: an https public URL (or
612// localhost) with a DNS name as host.
613func TestPasskeysAvailable(t *testing.T) {
614	t.Parallel()
615	for in, want := range map[string]struct {
616		ok     bool
617		reason string
618	}{
619		"https://git.example.com":      {true, ""},
620		"https://git.example.com:8443": {true, ""},
621		"https://GIT.example.com/":     {true, ""},
622		"http://localhost:7341":        {true, ""},
623		"http://127.0.0.1:7341":        {false, "public URL host is an IP address"},
624		"http://[::1]:7341":            {false, "public URL host is an IP address"},
625		"https://203.0.113.7":          {false, "public URL host is an IP address"},
626		"https://[2001:db8::1]":        {false, "public URL host is an IP address"},
627		"http://git.example.com":       {false, "needs https"},
628		"http://192.168.1.2:7341":      {false, "needs https"},
629		"":                             {false, "public URL has no host"},
630		"https://":                     {false, "public URL has no host"},
631	} {
632		ok, reason := passkeysAvailable(in)
633		assert.Equal(t, want.ok, ok, in)
634		assert.Equal(t, want.reason, reason, in)
635	}
636}

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 43

↑↓ moveenter openesc close