v0.4.0 3027a40

sessions_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6	"context"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/golang-jwt/jwt/v5"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite/internal/app"
17	"bnema.dev/ermite/internal/domain"
18)
19
20// memSessions is an in-memory ports.SessionStore.
21type memSessions struct {
22	mu    sync.Mutex
23	key   []byte
24	epoch uint64
25}
26
27func (m *memSessions) Key(context.Context) ([]byte, error) { return m.key, nil }
28func (m *memSessions) Epoch(context.Context) (uint64, error) {
29	m.mu.Lock()
30	defer m.mu.Unlock()
31	return m.epoch, nil
32}
33
34func (m *memSessions) BumpEpoch(context.Context) (uint64, error) {
35	m.mu.Lock()
36	defer m.mu.Unlock()
37	m.epoch++
38	return m.epoch, nil
39}
40
41// testClock is a clock the test moves.
42type testClock struct {
43	mu sync.Mutex
44	t  time.Time
45}
46
47func (c *testClock) Now() time.Time {
48	c.mu.Lock()
49	defer c.mu.Unlock()
50	return c.t
51}
52
53func (c *testClock) advance(d time.Duration) {
54	c.mu.Lock()
55	defer c.mu.Unlock()
56	c.t = c.t.Add(d)
57}
58
59type sessionFixture struct {
60	store    *memSessions
61	clock    *testClock
62	sessions *app.Sessions
63	key      []byte
64}
65
66func newSessionFixture(t *testing.T) *sessionFixture {
67	t.Helper()
68	f := &sessionFixture{
69		store: &memSessions{key: []byte("0123456789abcdef0123456789abcdef")},
70		clock: &testClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)},
71	}
72	f.key = f.store.key
73	s, err := app.NewSessions(context.Background(), f.store, f.clock, "https://forge.example")
74	require.NoError(t, err)
75	f.sessions = s
76	return f
77}
78
79func (f *sessionFixture) nonce(t *testing.T) string {
80	t.Helper()
81	link, err := f.sessions.CreateLoginLink(context.Background(), domain.Root())
82	require.NoError(t, err)
83	n, ok := strings.CutPrefix(link.URL, "https://forge.example/-/login/")
84	require.True(t, ok, link.URL)
85	return n
86}
87
88func allScopeToken(repos ...domain.RepoName) domain.Principal {
89	return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), Repos: repos})
90}
91
92// Only the owner creates a link: root, or a token with every scope and no
93// repository list.
94func TestLoginLinkIsOwnerOnly(t *testing.T) {
95	ctx := context.Background()
96	f := newSessionFixture(t)
97	partial := domain.TokenPrincipal(domain.Token{ID: "p", Scopes: domain.AllScopes()[:8]})
98	limited := allScopeToken(mustRepo(t, "a"))
99
100	for name, tc := range map[string]struct {
101		p   domain.Principal
102		err error
103	}{
104		"root":            {domain.Root(), nil},
105		"owner session":   {domain.Owner(), nil},
106		"every scope":     {allScopeToken(), nil},
107		"one scope short": {partial, domain.ErrForbidden},
108		"repo list":       {limited, domain.ErrForbidden},
109		"anonymous":       {domain.Anonymous(), domain.ErrUnauthenticated},
110	} {
111		_, err := f.sessions.CreateLoginLink(ctx, tc.p)
112		if tc.err == nil {
113			assert.NoError(t, err, name)
114		} else {
115			assert.ErrorIs(t, err, tc.err, name)
116		}
117		err = f.sessions.SignOutEverywhere(ctx, tc.p)
118		if tc.err == nil {
119			assert.NoError(t, err, name)
120		} else {
121			assert.ErrorIs(t, err, tc.err, name)
122		}
123	}
124}
125
126func mustRepo(t *testing.T, s string) domain.RepoName {
127	t.Helper()
128	n, err := domain.ParseRepoName(s)
129	require.NoError(t, err)
130	return n
131}
132
133// AC1: checking a link does not use it; logging in uses it once; an expired or
134// replayed link fails.
135func TestLoginLinkLifecycle(t *testing.T) {
136	ctx := context.Background()
137	f := newSessionFixture(t)
138
139	link, err := f.sessions.CreateLoginLink(ctx, domain.Root())
140	require.NoError(t, err)
141	assert.Equal(t, f.clock.Now().Add(app.LoginLinkTTL), link.ExpiresAt)
142	nonce := strings.TrimPrefix(link.URL, "https://forge.example/-/login/")
143	assert.Len(t, nonce, 43, "256 bits")
144
145	for range 3 {
146		assert.True(t, f.sessions.LoginLinkValid(nonce), "looking never consumes")
147	}
148	token, exp, err := f.sessions.Login(ctx, nonce)
149	require.NoError(t, err)
150	assert.NotEmpty(t, token)
151	assert.Equal(t, f.clock.Now().Add(app.SessionTTL), exp)
152	assert.False(t, f.sessions.LoginLinkValid(nonce))
153	_, _, err = f.sessions.Login(ctx, nonce)
154	assert.ErrorIs(t, err, domain.ErrUnauthenticated, "replay")
155
156	// Expired.
157	nonce = f.nonce(t)
158	f.clock.advance(app.LoginLinkTTL)
159	assert.False(t, f.sessions.LoginLinkValid(nonce))
160	_, _, err = f.sessions.Login(ctx, nonce)
161	assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired")
162
163	// Unknown.
164	_, _, err = f.sessions.Login(ctx, strings.Repeat("a", 43))
165	assert.ErrorIs(t, err, domain.ErrUnauthenticated)
166
167	// Two links are independent.
168	a, b := f.nonce(t), f.nonce(t)
169	assert.NotEqual(t, a, b)
170	_, _, err = f.sessions.Login(ctx, a)
171	require.NoError(t, err)
172	assert.True(t, f.sessions.LoginLinkValid(b))
173}
174
175// GO-003a: the session of a link made with a token never outlives that token;
176// root and a token that never expires leave it at SessionTTL.
177func TestSessionIsCappedByTheTokenThatMadeTheLink(t *testing.T) {
178	ctx := context.Background()
179	f := newSessionFixture(t)
180	tokenExpiring := func(in time.Duration) domain.Principal {
181		return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: domain.AllScopes(), ExpiresAt: f.clock.Now().Add(in)})
182	}
183	login := func(p domain.Principal) (string, time.Time, error) {
184		link, err := f.sessions.CreateLoginLink(ctx, p)
185		require.NoError(t, err)
186		return f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
187	}
188	start := f.clock.Now()
189
190	for name, tc := range map[string]struct {
191		p    domain.Principal
192		want time.Time
193	}{
194		"root":                {domain.Root(), start.Add(app.SessionTTL)},
195		"owner session":       {domain.Owner(), start.Add(app.SessionTTL)},
196		"never expires":       {allScopeToken(), start.Add(app.SessionTTL)},
197		"expires sooner":      {tokenExpiring(2 * time.Hour), start.Add(2 * time.Hour)},
198		"expires later":       {tokenExpiring(48 * time.Hour), start.Add(app.SessionTTL)},
199		"expires exactly TTL": {tokenExpiring(app.SessionTTL), start.Add(app.SessionTTL)},
200	} {
201		tok, exp, err := login(tc.p)
202		require.NoError(t, err, name)
203		assert.Equal(t, tc.want, exp, name)
204		_, err = f.sessions.Verify(ctx, tok)
205		assert.NoError(t, err, name)
206		// The session token itself carries the cap as its exp claim.
207		var c claims
208		_, _, err = jwt.NewParser().ParseUnverified(tok, &c)
209		require.NoError(t, err, name)
210		assert.Equal(t, tc.want, c.ExpiresAt.UTC(), name)
211	}
212
213	// It stops working when the token would have.
214	tok, _, err := login(tokenExpiring(2 * time.Hour))
215	require.NoError(t, err)
216	f.clock.advance(2*time.Hour - time.Second)
217	_, err = f.sessions.Verify(ctx, tok)
218	assert.NoError(t, err)
219	f.clock.advance(time.Second)
220	_, err = f.sessions.Verify(ctx, tok)
221	assert.ErrorIs(t, err, domain.ErrUnauthenticated, "the token expired, so did the session")
222
223	// A token that expires before the link is used opens nothing.
224	link, err := f.sessions.CreateLoginLink(ctx, tokenExpiring(30*time.Second))
225	require.NoError(t, err)
226	f.clock.advance(31 * time.Second)
227	_, _, err = f.sessions.Login(ctx, strings.TrimPrefix(link.URL, "https://forge.example/-/login/"))
228	assert.ErrorIs(t, err, domain.ErrUnauthenticated)
229}
230
231// A single use also holds under concurrent confirmations.
232func TestLoginLinkConsumedOnceConcurrently(t *testing.T) {
233	f := newSessionFixture(t)
234	nonce := f.nonce(t)
235	var wg sync.WaitGroup
236	var mu sync.Mutex
237	won := 0
238	for range 16 {
239		wg.Add(1)
240		go func() {
241			defer wg.Done()
242			if _, _, err := f.sessions.Login(context.Background(), nonce); err == nil {
243				mu.Lock()
244				won++
245				mu.Unlock()
246			}
247		}()
248	}
249	wg.Wait()
250	assert.Equal(t, 1, won)
251}
252
253func TestLoginLinksAreBounded(t *testing.T) {
254	ctx := context.Background()
255	f := newSessionFixture(t)
256	for range app.MaxPendingLoginLinks {
257		f.nonce(t)
258	}
259	_, err := f.sessions.CreateLoginLink(ctx, domain.Root())
260	assert.ErrorIs(t, err, domain.ErrResourceExhausted)
261
262	f.clock.advance(app.LoginLinkTTL)
263	_, err = f.sessions.CreateLoginLink(ctx, domain.Root())
264	assert.NoError(t, err, "expired links make room")
265}
266
267func sign(t *testing.T, m jwt.SigningMethod, key any, claims jwt.Claims) string {
268	t.Helper()
269	s, err := jwt.NewWithClaims(m, claims).SignedString(key)
270	require.NoError(t, err)
271	return s
272}
273
274type claims struct {
275	Epoch *uint64 `json:"epoch"`
276	jwt.RegisteredClaims
277}
278
279// AC1: a valid session is the owner; none, alg none, another algorithm, another
280// key, an old epoch, expiry, a missing claim and garbage are rejected.
281func TestSessionTokenVerification(t *testing.T) {
282	ctx := context.Background()
283	f := newSessionFixture(t)
284	now := f.clock.Now()
285	zero, one := uint64(0), uint64(1)
286	reg := func(sub string, iat, exp time.Time) jwt.RegisteredClaims {
287		return jwt.RegisteredClaims{Subject: sub, IssuedAt: jwt.NewNumericDate(iat), ExpiresAt: jwt.NewNumericDate(exp)}
288	}
289	good := reg("owner", now, now.Add(time.Hour))
290
291	token, _, err := f.sessions.Login(ctx, f.nonce(t))
292	require.NoError(t, err)
293	p, err := f.sessions.Verify(ctx, token)
294	require.NoError(t, err)
295	assert.True(t, p.IsOwner())
296	assert.Equal(t, "owner", p.Label())
297
298	cases := map[string]string{
299		"alg none":       sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, claims{&zero, good}),
300		"hs384":          sign(t, jwt.SigningMethodHS384, f.key, claims{&zero, good}),
301		"hs512":          sign(t, jwt.SigningMethodHS512, f.key, claims{&zero, good}),
302		"wrong key":      sign(t, jwt.SigningMethodHS256, []byte("another key of 32 bytes.........."), claims{&zero, good}),
303		"old epoch":      sign(t, jwt.SigningMethodHS256, f.key, claims{&one, good}),
304		"no epoch":       sign(t, jwt.SigningMethodHS256, f.key, claims{nil, good}),
305		"expired":        sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(-2*time.Hour), now.Add(-time.Hour))}),
306		"no expiry":      sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, jwt.RegisteredClaims{Subject: "owner", IssuedAt: jwt.NewNumericDate(now)}}),
307		"future iat":     sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("owner", now.Add(time.Hour), now.Add(2*time.Hour))}),
308		"other subject":  sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, reg("someone", now, now.Add(time.Hour))}),
309		"empty":          "",
310		"garbage":        "not.a.jwt",
311		"truncated":      token[:len(token)-3],
312		"signature swap": token[:strings.LastIndex(token, ".")] + ".AAAA",
313		"payload only":   strings.Split(token, ".")[1],
314		"three empty":    "..",
315	}
316	for name, tok := range cases {
317		_, err := f.sessions.Verify(ctx, tok)
318		assert.ErrorIs(t, err, domain.ErrUnauthenticated, name)
319	}
320	// The control: the same claims signed right are accepted.
321	_, err = f.sessions.Verify(ctx, sign(t, jwt.SigningMethodHS256, f.key, claims{&zero, good}))
322	assert.NoError(t, err)
323
324	// Expiry after the session lifetime; and the epoch ends every session.
325	f.clock.advance(app.SessionTTL)
326	_, err = f.sessions.Verify(ctx, token)
327	assert.ErrorIs(t, err, domain.ErrUnauthenticated, "expired after 12 h")
328}
329
330func TestSignOutEverywhereEndsSessions(t *testing.T) {
331	ctx := context.Background()
332	f := newSessionFixture(t)
333	a, _, err := f.sessions.Login(ctx, f.nonce(t))
334	require.NoError(t, err)
335	b, _, err := f.sessions.Login(ctx, f.nonce(t))
336	require.NoError(t, err)
337	pending := f.nonce(t)
338
339	require.NoError(t, f.sessions.SignOutEverywhere(ctx, domain.Root()))
340	for _, tok := range []string{a, b} {
341		_, err := f.sessions.Verify(ctx, tok)
342		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
343	}
344	assert.True(t, f.sessions.LoginLinkValid(pending), "pending links are not affected")
345	c, _, err := f.sessions.Login(ctx, pending)
346	require.NoError(t, err)
347	_, err = f.sessions.Verify(ctx, c)
348	assert.NoError(t, err, "a new session works")
349}
350
351// The owner signed in on the web signs what it writes as "owner", where root
352// signs as "root" although both hold every right.
353func TestAuthorOfOwnerSession(t *testing.T) {
354	assert.Equal(t, "owner", app.AuthorOf(domain.Owner()))
355	assert.Equal(t, "root", app.AuthorOf(domain.Root()))
356	assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()})))
357}

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