detached 98d36bf

tokens_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"encoding/base64"
8	"errors"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20	"bnema.dev/ermite/internal/usecase"
21)
22
23type tokenFixture struct {
24	store    *mocks.TokenStore
25	clock    *mocks.Clock
26	sessions *mocks.SessionStore
27	tokens   *usecase.Tokens
28	auth     *usecase.Auth
29}
30
31var rootSecret = "root-secret-0123456789abcdef0123456789"
32
33func newTokenFixture(t *testing.T) *tokenFixture {
34	t.Helper()
35	f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t), sessions: mocks.NewSessionStore(t)}
36	f.clock.EXPECT().Now().Return(now).Maybe()
37	f.tokens = usecase.NewTokens(f.store, f.clock, f.sessions)
38	f.auth = usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
39	return f
40}
41
42func scopes(s ...domain.Scope) []domain.Scope { return s }
43
44func names(t *testing.T, ss ...string) []domain.RepoName {
45	t.Helper()
46	var out []domain.RepoName
47	for _, s := range ss {
48		out = append(out, name(t, s))
49	}
50	return out
51}
52
53// Labels that only contain a reserved word are fine.
54func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) {
55	t.Parallel()
56	ctx := context.Background()
57	tests := []struct{ name, label string }{
58		{"digit suffix", "owner2"},
59		{"prefixed with a word", "my owner"},
60		{"plural", "roots"},
61		{"split word", "ow ner"},
62		{"hyphenated", "root-ci"},
63	}
64	for _, tt := range tests {
65		t.Run(tt.name, func(t *testing.T) {
66			t.Parallel()
67			f := newTokenFixture(t)
68			f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
69			_, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: tt.label, Scopes: scopes(domain.ScopeRepoRead)})
70			assert.NoError(t, err)
71		})
72	}
73}
74
75func TestTokensCreate(t *testing.T) {
76	t.Parallel()
77	ctx := context.Background()
78
79	t.Run("root creates a token with a well-formed secret", func(t *testing.T) {
80		t.Parallel()
81		f := newTokenFixture(t)
82		var stored domain.Token
83		f.store.EXPECT().Create(ctx, mock.Anything).Run(func(_ context.Context, tok domain.Token) { stored = tok }).Return(nil)
84
85		exp := now.Add(48 * time.Hour)
86		got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
87			Label:     "ci",
88			Scopes:    scopes(domain.ScopeRepoWrite, domain.ScopeRepoRead, domain.ScopeRepoRead),
89			Repos:     names(t, "b", "a", "b"),
90			ExpiresAt: exp,
91		})
92		require.NoError(t, err)
93
94		assert.True(t, strings.HasPrefix(got.Secret, "ermite_"))
95		raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got.Secret, "ermite_"))
96		require.NoError(t, err)
97		assert.Len(t, raw, 32)
98
99		assert.Equal(t, stored, got.Token)
100		assert.Equal(t, domain.HashTokenSecret(got.Secret), got.Token.Hash)
101		assert.Len(t, got.Token.ID, 16)
102		assert.Equal(t, scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), got.Token.Scopes, "canonical order, deduplicated")
103		assert.Equal(t, names(t, "a", "b"), got.Token.Repos, "sorted, deduplicated")
104		assert.Equal(t, now, got.Token.CreatedAt)
105		assert.True(t, exp.Equal(got.Token.ExpiresAt))
106		assert.NotContains(t, got.Token.Label, got.Secret)
107	})
108
109	t.Run("secrets are unique", func(t *testing.T) {
110		t.Parallel()
111		f := newTokenFixture(t)
112		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
113		a, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
114		require.NoError(t, err)
115		b, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
116		require.NoError(t, err)
117		assert.NotEqual(t, a.Secret, b.Secret)
118		assert.NotEqual(t, a.Token.ID, b.Token.ID)
119	})
120
121	t.Run("retries on a collision", func(t *testing.T) {
122		t.Parallel()
123		f := newTokenFixture(t)
124		f.store.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists).Once()
125		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil).Once()
126		_, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
127		require.NoError(t, err)
128	})
129
130	t.Run("store failure", func(t *testing.T) {
131		t.Parallel()
132		f := newTokenFixture(t)
133		boom := errors.New("boom")
134		f.store.EXPECT().Create(ctx, mock.Anything).Return(boom)
135		_, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
136		assert.ErrorIs(t, err, boom)
137	})
138
139	invalid := []struct {
140		name   string
141		params ports.CreateTokenParams
142		want   error
143	}{
144		{"empty label", ports.CreateTokenParams{Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
145		{"long label", ports.CreateTokenParams{Label: strings.Repeat("x", 101), Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
146		{"bad utf8 label", ports.CreateTokenParams{Label: "\xff", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
147		{"reserved owner", ports.CreateTokenParams{Label: "owner", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
148		{"reserved root", ports.CreateTokenParams{Label: "root", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
149		{"reserved, upper case", ports.CreateTokenParams{Label: "OWNER", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
150		{"reserved, mixed case", ports.CreateTokenParams{Label: "Root", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
151		{"reserved, padded", ports.CreateTokenParams{Label: " \towner \n", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
152		{"no scopes", ports.CreateTokenParams{Label: "x"}, domain.ErrInvalidToken},
153		{"unknown scope", ports.CreateTokenParams{Label: "x", Scopes: scopes("nope")}, domain.ErrInvalidScope},
154		{"past expiry", ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: now}, domain.ErrInvalidToken},
155		{"zero repo name", ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: []domain.RepoName{{}}}, domain.ErrInvalidToken},
156	}
157	for _, tt := range invalid {
158		t.Run("invalid/"+tt.name, func(t *testing.T) {
159			t.Parallel()
160			f := newTokenFixture(t)
161			_, err := f.tokens.Create(ctx, domain.Root(), tt.params)
162			assert.ErrorIs(t, err, tt.want)
163		})
164	}
165}
166
167func TestTokensCreateNoEscalation(t *testing.T) {
168	t.Parallel()
169	ctx := context.Background()
170	exp := now.Add(time.Hour)
171	admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
172		return domain.TokenPrincipal(domain.Token{
173			ID:        "creator",
174			Scopes:    append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
175			Repos:     repos,
176			ExpiresAt: expires,
177		})
178	}
179
180	adminOn := func(prefixes []string) domain.Principal {
181		return domain.TokenPrincipal(domain.Token{
182			ID:             "creator",
183			Scopes:         scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite),
184			BranchPrefixes: prefixes,
185		})
186	}
187
188	tests := []struct {
189		name      string
190		principal domain.Principal
191		params    ports.CreateTokenParams
192		want      error // nil = allowed
193	}{
194		{"anonymous", domain.Anonymous(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrUnauthenticated},
195		{"token without token:admin",
196			domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}),
197			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
198		{"subset of scopes", admin(nil, time.Time{}),
199			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, nil},
200		{"same scopes incl. token:admin", admin(nil, time.Time{}),
201			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite)}, nil},
202		{"scope it lacks", admin(nil, time.Time{}),
203			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin)}, domain.ErrForbidden},
204		{"limited creator, same repos", admin(names(t, "a", "b"), time.Time{}),
205			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a")}, nil},
206		{"limited creator, repo outside its list", admin(names(t, "a"), time.Time{}),
207			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a", "z")}, domain.ErrForbidden},
208		{"limited creator, unlimited child", admin(names(t, "a"), time.Time{}),
209			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
210		{"unlimited creator may limit", admin(nil, time.Time{}),
211			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "z")}, nil},
212		{"expiring creator, child never expires", admin(nil, exp),
213			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
214		{"expiring creator, child outlasts", admin(nil, exp),
215			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(time.Second)}, domain.ErrForbidden},
216		{"expiring creator, child within", admin(nil, exp),
217			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(-time.Second)}, nil},
218		{"expiring creator, child same expiry", admin(nil, exp),
219			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
220		{"root: everything", domain.Root(),
221			ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
222		{"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}),
223			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil},
224		{"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}),
225			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil},
226		{"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}),
227			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil},
228		{"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}),
229			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
230		{"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}),
231			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden},
232		{"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}),
233			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden},
234		{"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}),
235			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden},
236		{"unlimited creator may limit branches", admin(nil, time.Time{}),
237			ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil},
238	}
239	for _, tt := range tests {
240		t.Run(tt.name, func(t *testing.T) {
241			t.Parallel()
242			f := newTokenFixture(t)
243			if tt.want == nil {
244				f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
245			}
246			got, err := f.tokens.Create(ctx, tt.principal, tt.params)
247			if tt.want != nil {
248				assert.ErrorIs(t, err, tt.want)
249				assert.Empty(t, got.Secret)
250				return
251			}
252			require.NoError(t, err)
253			assert.NotEmpty(t, got.Secret)
254		})
255	}
256}
257
258func TestTokensListRevoke(t *testing.T) {
259	t.Parallel()
260	ctx := context.Background()
261	tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
262	writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
263
264	t.Run("list for the admin token and root", func(t *testing.T) {
265		t.Parallel()
266		f := newTokenFixture(t)
267		want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}}
268		f.store.EXPECT().List(ctx).Return(want, nil).Twice()
269		for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
270			got, err := f.tokens.List(ctx, p)
271			require.NoError(t, err)
272			assert.Equal(t, want, got)
273		}
274		_, err := f.tokens.List(ctx, writer)
275		assert.ErrorIs(t, err, domain.ErrForbidden)
276		_, err = f.tokens.List(ctx, domain.Anonymous())
277		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
278	})
279
280	t.Run("revoke", func(t *testing.T) {
281		t.Parallel()
282		f := newTokenFixture(t)
283		f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Twice()
284		f.store.EXPECT().Get(ctx, "zz").Return(domain.Token{}, domain.ErrNotFound).Once()
285		f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice()
286		require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
287		require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a"))
288		assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
289		assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
290		assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
291	})
292}
293
294// GO-003b: revoking a token with owner rights signs every browser out (the
295// session epoch is bumped); revoking any other token does not.
296func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) {
297	t.Parallel()
298	ctx := context.Background()
299	ownerTok := domain.Token{ID: "own", Scopes: domain.AllScopes()}
300
301	for name, tc := range map[string]struct {
302		tok    domain.Token
303		by     domain.Principal
304		bumped bool
305	}{
306		"every scope, by root":         {ownerTok, domain.Root(), true},
307		"every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true},
308		"one scope short":              {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false},
309		"repo list":                    {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false},
310		"branch prefixes":              {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false},
311	} {
312		t.Run(name, func(t *testing.T) {
313			t.Parallel()
314			f := newTokenFixture(t)
315			f.store.EXPECT().Get(ctx, tc.tok.ID).Return(tc.tok, nil)
316			f.store.EXPECT().Revoke(ctx, tc.tok.ID).Return(nil)
317			if tc.bumped {
318				f.sessions.EXPECT().BumpEpoch(ctx).Return(1, nil).Once()
319			}
320			require.NoError(t, f.tokens.Revoke(ctx, tc.by, tc.tok.ID))
321		})
322	}
323
324	t.Run("a revoke that fails bumps nothing", func(t *testing.T) {
325		t.Parallel()
326		f := newTokenFixture(t)
327		f.store.EXPECT().Get(ctx, "own").Return(ownerTok, nil)
328		f.store.EXPECT().Revoke(ctx, "own").Return(errors.New("kv down"))
329		assert.Error(t, f.tokens.Revoke(ctx, domain.Root(), "own"))
330	})
331
332	t.Run("a token out of reach is not revoked and bumps nothing", func(t *testing.T) {
333		t.Parallel()
334		f := newTokenFixture(t)
335		limited := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
336		f.store.EXPECT().Get(ctx, "own").Return(ownerTok, nil)
337		assert.ErrorIs(t, f.tokens.Revoke(ctx, limited, "own"), domain.ErrNotFound)
338	})
339
340	t.Run("a failure to sign out is reported once the token is revoked", func(t *testing.T) {
341		t.Parallel()
342		f := newTokenFixture(t)
343		f.store.EXPECT().Get(ctx, "own").Return(ownerTok, nil)
344		f.store.EXPECT().Revoke(ctx, "own").Return(nil)
345		f.sessions.EXPECT().BumpEpoch(ctx).Return(0, errors.New("kv down"))
346		assert.ErrorContains(t, f.tokens.Revoke(ctx, domain.Root(), "own"), "signing out")
347	})
348}
349
350// A token admin sees and revokes only the tokens it could have created: the
351// no-escalation rule of Create, applied to scopes, repositories and expiry.
352func TestTokensAdminReach(t *testing.T) {
353	t.Parallel()
354	ctx := context.Background()
355	exp := now.Add(48 * time.Hour)
356	admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
357		return domain.TokenPrincipal(domain.Token{
358			ID:        "caller",
359			Scopes:    append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
360			Repos:     repos,
361			ExpiresAt: expires,
362		})
363	}
364	tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token {
365		return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires}
366	}
367	rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite)
368	ab, a := names(t, "a", "b"), names(t, "a")
369
370	tests := []struct {
371		name   string
372		caller domain.Principal
373		target domain.Token
374		within bool
375	}{
376		{"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true},
377		{"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true},
378		{"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false},
379		{"root-only reach: a token:admin caller cannot reach a mr:merge token", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeMRMerge), nil, time.Time{}), false},
380		{"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false},
381		{"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false},
382		{"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true},
383		{"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true},
384		{"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false},
385		{"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false},
386		{"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true},
387		{"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true},
388	}
389	for _, tt := range tests {
390		t.Run("list: "+tt.name, func(t *testing.T) {
391			t.Parallel()
392			f := newTokenFixture(t)
393			other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{})
394			f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil)
395			got, err := f.tokens.List(ctx, tt.caller)
396			require.NoError(t, err)
397			if !tt.within {
398				// Only the token that is within reach of every caller shows.
399				assert.NotContains(t, got, tt.target)
400				return
401			}
402			assert.Contains(t, got, tt.target)
403		})
404		t.Run("revoke: "+tt.name, func(t *testing.T) {
405			t.Parallel()
406			f := newTokenFixture(t)
407			f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil)
408			if tt.within {
409				f.store.EXPECT().Revoke(ctx, "t").Return(nil)
410				require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t"))
411				return
412			}
413			// Not leaked: the same answer as for a token that does not exist, and nothing is deleted.
414			assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound)
415		})
416	}
417
418	t.Run("root lists and revokes everything", func(t *testing.T) {
419		t.Parallel()
420		f := newTokenFixture(t)
421		all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)}
422		f.store.EXPECT().List(ctx).Return(all, nil)
423		got, err := f.tokens.List(ctx, domain.Root())
424		require.NoError(t, err)
425		assert.Equal(t, all, got)
426		f.store.EXPECT().Get(ctx, "x").Return(all[0], nil)
427		f.store.EXPECT().Revoke(ctx, "x").Return(nil)     // root has no limits
428		f.sessions.EXPECT().BumpEpoch(ctx).Return(1, nil) // "x" has owner rights
429		require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x"))
430	})
431
432	t.Run("an unknown id is not found for everyone", func(t *testing.T) {
433		t.Parallel()
434		f := newTokenFixture(t)
435		f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound)
436		assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound)
437	})
438
439	t.Run("a store failure on the lookup is returned", func(t *testing.T) {
440		t.Parallel()
441		f := newTokenFixture(t)
442		boom := errors.New("boom")
443		f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom)
444		assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom)
445	})
446}
447
448func TestAuthRevalidate(t *testing.T) {
449	t.Parallel()
450	ctx := context.Background()
451	p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)})
452
453	t.Run("root is always valid", func(t *testing.T) {
454		t.Parallel()
455		f := newTokenFixture(t)
456		require.NoError(t, f.auth.Revalidate(ctx, domain.Root()))
457	})
458	t.Run("anonymous is not", func(t *testing.T) {
459		t.Parallel()
460		f := newTokenFixture(t)
461		assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated)
462	})
463	t.Run("a stored token that exists", func(t *testing.T) {
464		t.Parallel()
465		f := newTokenFixture(t)
466		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil)
467		require.NoError(t, f.auth.Revalidate(ctx, p))
468	})
469	t.Run("revoked", func(t *testing.T) {
470		t.Parallel()
471		f := newTokenFixture(t)
472		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound)
473		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
474	})
475	t.Run("expired", func(t *testing.T) {
476		t.Parallel()
477		f := newTokenFixture(t)
478		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil)
479		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
480	})
481	t.Run("a store failure is an error, not a pass", func(t *testing.T) {
482		t.Parallel()
483		f := newTokenFixture(t)
484		boom := errors.New("boom")
485		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom)
486		err := f.auth.Revalidate(ctx, p)
487		require.ErrorIs(t, err, boom)
488		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
489	})
490}
491
492func TestAuthenticate(t *testing.T) {
493	t.Parallel()
494	ctx := context.Background()
495	secret := "ermite_stored-secret"
496	hash := domain.HashTokenSecret(secret)
497	tok := domain.Token{
498		ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"),
499		ExpiresAt: now.Add(time.Hour),
500	}
501
502	t.Run("root", func(t *testing.T) {
503		t.Parallel()
504		f := newTokenFixture(t)
505		p, err := f.auth.Authenticate(ctx, rootSecret)
506		require.NoError(t, err)
507		assert.True(t, p.IsRoot())
508	})
509
510	t.Run("empty and unknown secrets", func(t *testing.T) {
511		t.Parallel()
512		f := newTokenFixture(t)
513		_, err := f.auth.Authenticate(ctx, "")
514		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
515		f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound)
516		_, err = f.auth.Authenticate(ctx, "nope")
517		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
518	})
519
520	t.Run("stored token first use touches", func(t *testing.T) {
521		t.Parallel()
522		f := newTokenFixture(t)
523		f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
524		f.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
525		p, err := f.auth.Authenticate(ctx, secret)
526		require.NoError(t, err)
527		assert.Equal(t, "t1", p.TokenID())
528		assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a")))
529		assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b")))
530	})
531
532	t.Run("touch at most once a minute", func(t *testing.T) {
533		t.Parallel()
534		f := newTokenFixture(t)
535		recent := tok
536		recent.LastUsedAt = now.Add(-30 * time.Second)
537		f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil)
538		_, err := f.auth.Authenticate(ctx, secret) // no Touch expected
539		require.NoError(t, err)
540
541		f2 := newTokenFixture(t)
542		old := tok
543		old.LastUsedAt = now.Add(-time.Minute)
544		f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil)
545		f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
546		_, err = f2.auth.Authenticate(ctx, secret)
547		require.NoError(t, err)
548	})
549
550	t.Run("touch failure does not fail authentication", func(t *testing.T) {
551		t.Parallel()
552		f := newTokenFixture(t)
553		f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
554		f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk"))
555		_, err := f.auth.Authenticate(ctx, secret)
556		require.NoError(t, err)
557	})
558
559	t.Run("expired", func(t *testing.T) {
560		t.Parallel()
561		f := newTokenFixture(t)
562		exp := tok
563		exp.ExpiresAt = now
564		f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil)
565		_, err := f.auth.Authenticate(ctx, secret)
566		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
567	})
568
569	t.Run("store error is not an authentication failure", func(t *testing.T) {
570		t.Parallel()
571		f := newTokenFixture(t)
572		boom := errors.New("boom")
573		f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom)
574		_, err := f.auth.Authenticate(ctx, secret)
575		assert.ErrorIs(t, err, boom)
576		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
577	})
578}
579
580func TestTokensCreateBranchPrefixes(t *testing.T) {
581	t.Parallel()
582	ctx := context.Background()
583
584	t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) {
585		t.Parallel()
586		f := newTokenFixture(t)
587		f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool {
588			return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes)
589		})).Return(nil)
590		got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
591			Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"},
592		})
593		require.NoError(t, err)
594		assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes)
595	})
596
597	t.Run("no prefixes stays unlimited", func(t *testing.T) {
598		t.Parallel()
599		f := newTokenFixture(t)
600		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
601		got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
602		require.NoError(t, err)
603		assert.Empty(t, got.Token.BranchPrefixes)
604	})
605
606	t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) {
607		t.Parallel()
608		nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"}
609		for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} {
610			f := newTokenFixture(t) // the store expects no Create
611			_, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
612				Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad,
613			})
614			assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
615		}
616	})
617
618	t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) {
619		t.Parallel()
620		f := newTokenFixture(t) // the store expects no Create
621		corrupt := domain.TokenPrincipal(domain.Token{
622			ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""},
623		})
624		_, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{
625			Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"},
626		})
627		assert.ErrorIs(t, err, domain.ErrForbidden)
628	})
629
630	t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) {
631		t.Parallel()
632		ctx := context.Background()
633		creator := domain.TokenPrincipal(domain.Token{
634			ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
635		})
636		inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
637		wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
638		sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}
639		free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
640
641		f := newTokenFixture(t)
642		for _, tok := range []domain.Token{wide, sibling, free} {
643			f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once()
644		}
645		// Out of reach: not found, like an unknown id, and nothing is revoked.
646		for _, id := range []string{"wide", "sib", "free"} {
647			assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id)
648		}
649		// Within its prefixes it revokes, and no web session is signed out.
650		f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once()
651		f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once()
652		require.NoError(t, f.tokens.Revoke(ctx, creator, "in"))
653	})
654
655	t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) {
656		t.Parallel()
657		f := newTokenFixture(t)
658		mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
659		wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
660		free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
661		f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil)
662		p := domain.TokenPrincipal(domain.Token{
663			ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
664		})
665		got, err := f.tokens.List(ctx, p)
666		require.NoError(t, err)
667		require.Len(t, got, 1)
668		assert.Equal(t, "mine", got[0].ID)
669	})
670}
671
672// The job repository tokens of CI: Authenticate resolves them to a principal
673// shaped like a stored token, and only while they exist and have not expired.
674func TestAuthRepoTokens(t *testing.T) {
675	t.Parallel()
676	ctx := context.Background()
677	secret := domain.RepoTokenPrefix + strings.Repeat("a", domain.CITokenRandomLen)
678	h := domain.HashTokenSecret(secret)
679	repo := name(t, "proj")
680	tok := domain.CIRepoToken{ID: "id1", Hash: h, Repo: repo, ExpiresAt: now.Add(time.Minute)}
681
682	t.Run("a live token reads its repository", func(t *testing.T) {
683		t.Parallel()
684		f := newTokenFixture(t)
685		rt := mocks.NewCIRepoTokenStore(t)
686		rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(tok, nil)
687		f.auth.WithRepoTokens(rt)
688		p, err := f.auth.Authenticate(ctx, secret)
689		require.NoError(t, err)
690		assert.True(t, p.Allows(domain.ScopeRepoRead, repo))
691		assert.False(t, p.Allows(domain.ScopeRepoWrite, repo))
692		assert.False(t, p.Allows(domain.ScopeTokenAdmin, repo))
693	})
694	t.Run("the stored tokens are never asked", func(t *testing.T) {
695		t.Parallel()
696		f := newTokenFixture(t) // its TokenStore mock fails on any call
697		rt := mocks.NewCIRepoTokenStore(t)
698		rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, domain.ErrNotFound)
699		f.auth.WithRepoTokens(rt)
700		_, err := f.auth.Authenticate(ctx, secret)
701		assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked or unknown token")
702	})
703	t.Run("expired", func(t *testing.T) {
704		t.Parallel()
705		f := newTokenFixture(t)
706		rt := mocks.NewCIRepoTokenStore(t)
707		old := tok
708		old.ExpiresAt = now
709		rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(old, nil)
710		f.auth.WithRepoTokens(rt)
711		_, err := f.auth.Authenticate(ctx, secret)
712		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
713	})
714	t.Run("a store failure is not unauthenticated", func(t *testing.T) {
715		t.Parallel()
716		f := newTokenFixture(t)
717		rt := mocks.NewCIRepoTokenStore(t)
718		boom := errors.New("disk")
719		rt.EXPECT().FindRepoTokenByHash(ctx, h).Return(domain.CIRepoToken{}, boom)
720		f.auth.WithRepoTokens(rt)
721		_, err := f.auth.Authenticate(ctx, secret)
722		assert.ErrorIs(t, err, boom)
723		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
724	})
725	t.Run("without CI wired the shape is refused", func(t *testing.T) {
726		t.Parallel()
727		f := newTokenFixture(t)
728		_, err := f.auth.Authenticate(ctx, secret)
729		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
730	})
731	t.Run("revalidate follows the token, not the stored tokens", func(t *testing.T) {
732		t.Parallel()
733		f := newTokenFixture(t)
734		rt := mocks.NewCIRepoTokenStore(t)
735		rt.EXPECT().GetRepoToken(ctx, "id1").Return(tok, nil).Once()
736		rt.EXPECT().GetRepoToken(ctx, "id1").Return(domain.CIRepoToken{}, domain.ErrNotFound).Once()
737		f.auth.WithRepoTokens(rt)
738		p := domain.RepoTokenPrincipal(tok)
739		assert.NoError(t, f.auth.Revalidate(ctx, p))
740		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated, "revoked since")
741	})
742}

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