v0.10.1 e4c85c7

sessions_test.go RawHistoryFolder

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

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