v0.5.0 bfab294

tokens_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_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/app"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports/mocks"
20)
21
22type tokenFixture struct {
23	store    *mocks.TokenStore
24	clock    *mocks.Clock
25	sessions *mocks.SessionStore
26	tokens   *app.Tokens
27	auth     *app.Auth
28}
29
30var rootSecret = "root-secret-0123456789abcdef0123456789"
31
32func newTokenFixture(t *testing.T) *tokenFixture {
33	t.Helper()
34	f := &tokenFixture{store: mocks.NewTokenStore(t), clock: mocks.NewClock(t), sessions: mocks.NewSessionStore(t)}
35	f.clock.EXPECT().Now().Return(now).Maybe()
36	f.tokens = app.NewTokens(f.store, f.clock, f.sessions)
37	f.auth = app.NewAuth(domain.HashTokenSecret(rootSecret), f.store, f.clock)
38	return f
39}
40
41func scopes(s ...domain.Scope) []domain.Scope { return s }
42
43func names(t *testing.T, ss ...string) []domain.RepoName {
44	t.Helper()
45	var out []domain.RepoName
46	for _, s := range ss {
47		out = append(out, name(t, s))
48	}
49	return out
50}
51
52// Labels that only contain a reserved word are fine.
53func TestTokensCreateAllowsLabelsNearReserved(t *testing.T) {
54	ctx := context.Background()
55	for _, label := range []string{"owner2", "my owner", "roots", "ow ner", "root-ci"} {
56		f := newTokenFixture(t)
57		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
58		_, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: label, Scopes: scopes(domain.ScopeRepoRead)})
59		assert.NoError(t, err, label)
60	}
61}
62
63func TestTokensCreate(t *testing.T) {
64	ctx := context.Background()
65
66	t.Run("root creates a token with a well-formed secret", func(t *testing.T) {
67		f := newTokenFixture(t)
68		var stored domain.Token
69		f.store.EXPECT().Create(ctx, mock.Anything).Run(func(_ context.Context, tok domain.Token) { stored = tok }).Return(nil)
70
71		exp := now.Add(48 * time.Hour)
72		got, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{
73			Label:     "ci",
74			Scopes:    scopes(domain.ScopeRepoWrite, domain.ScopeRepoRead, domain.ScopeRepoRead),
75			Repos:     names(t, "b", "a", "b"),
76			ExpiresAt: exp,
77		})
78		require.NoError(t, err)
79
80		assert.True(t, strings.HasPrefix(got.Secret, "ermite_"))
81		raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(got.Secret, "ermite_"))
82		require.NoError(t, err)
83		assert.Len(t, raw, 32)
84
85		assert.Equal(t, stored, got.Token)
86		assert.Equal(t, domain.HashTokenSecret(got.Secret), got.Token.Hash)
87		assert.Len(t, got.Token.ID, 16)
88		assert.Equal(t, scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), got.Token.Scopes, "canonical order, deduplicated")
89		assert.Equal(t, names(t, "a", "b"), got.Token.Repos, "sorted, deduplicated")
90		assert.Equal(t, now, got.Token.CreatedAt)
91		assert.True(t, exp.Equal(got.Token.ExpiresAt))
92		assert.NotContains(t, got.Token.Label, got.Secret)
93	})
94
95	t.Run("secrets are unique", func(t *testing.T) {
96		f := newTokenFixture(t)
97		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
98		a, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
99		require.NoError(t, err)
100		b, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
101		require.NoError(t, err)
102		assert.NotEqual(t, a.Secret, b.Secret)
103		assert.NotEqual(t, a.Token.ID, b.Token.ID)
104	})
105
106	t.Run("retries on a collision", func(t *testing.T) {
107		f := newTokenFixture(t)
108		f.store.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists).Once()
109		f.store.EXPECT().Create(ctx, mock.Anything).Return(nil).Once()
110		_, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
111		require.NoError(t, err)
112	})
113
114	t.Run("store failure", func(t *testing.T) {
115		f := newTokenFixture(t)
116		boom := errors.New("boom")
117		f.store.EXPECT().Create(ctx, mock.Anything).Return(boom)
118		_, err := f.tokens.Create(ctx, domain.Root(), app.CreateTokenParams{Label: "a", Scopes: scopes(domain.ScopeRepoRead)})
119		assert.ErrorIs(t, err, boom)
120	})
121
122	invalid := []struct {
123		name   string
124		params app.CreateTokenParams
125		want   error
126	}{
127		{"empty label", app.CreateTokenParams{Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
128		{"long label", app.CreateTokenParams{Label: strings.Repeat("x", 101), Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
129		{"bad utf8 label", app.CreateTokenParams{Label: "\xff", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
130		{"reserved owner", app.CreateTokenParams{Label: "owner", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
131		{"reserved root", app.CreateTokenParams{Label: "root", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
132		{"reserved, upper case", app.CreateTokenParams{Label: "OWNER", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
133		{"reserved, mixed case", app.CreateTokenParams{Label: "Root", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
134		{"reserved, padded", app.CreateTokenParams{Label: " \towner \n", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrInvalidToken},
135		{"no scopes", app.CreateTokenParams{Label: "x"}, domain.ErrInvalidToken},
136		{"unknown scope", app.CreateTokenParams{Label: "x", Scopes: scopes("nope")}, domain.ErrInvalidScope},
137		{"past expiry", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: now}, domain.ErrInvalidToken},
138		{"zero repo name", app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: []domain.RepoName{{}}}, domain.ErrInvalidToken},
139	}
140	for _, tt := range invalid {
141		t.Run("invalid/"+tt.name, func(t *testing.T) {
142			f := newTokenFixture(t)
143			_, err := f.tokens.Create(ctx, domain.Root(), tt.params)
144			assert.ErrorIs(t, err, tt.want)
145		})
146	}
147}
148
149func TestTokensCreateNoEscalation(t *testing.T) {
150	ctx := context.Background()
151	exp := now.Add(time.Hour)
152	admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
153		return domain.TokenPrincipal(domain.Token{
154			ID:        "creator",
155			Scopes:    append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
156			Repos:     repos,
157			ExpiresAt: expires,
158		})
159	}
160
161	tests := []struct {
162		name      string
163		principal domain.Principal
164		params    app.CreateTokenParams
165		want      error // nil = allowed
166	}{
167		{"anonymous", domain.Anonymous(), app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrUnauthenticated},
168		{"token without token:admin",
169			domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}),
170			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
171		{"subset of scopes", admin(nil, time.Time{}),
172			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, nil},
173		{"same scopes incl. token:admin", admin(nil, time.Time{}),
174			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite)}, nil},
175		{"scope it lacks", admin(nil, time.Time{}),
176			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin)}, domain.ErrForbidden},
177		{"limited creator, same repos", admin(names(t, "a", "b"), time.Time{}),
178			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a")}, nil},
179		{"limited creator, repo outside its list", admin(names(t, "a"), time.Time{}),
180			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "a", "z")}, domain.ErrForbidden},
181		{"limited creator, unlimited child", admin(names(t, "a"), time.Time{}),
182			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
183		{"unlimited creator may limit", admin(nil, time.Time{}),
184			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), Repos: names(t, "z")}, nil},
185		{"expiring creator, child never expires", admin(nil, exp),
186			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
187		{"expiring creator, child outlasts", admin(nil, exp),
188			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(time.Second)}, domain.ErrForbidden},
189		{"expiring creator, child within", admin(nil, exp),
190			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp.Add(-time.Second)}, nil},
191		{"expiring creator, child same expiry", admin(nil, exp),
192			app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
193		{"root: everything", domain.Root(),
194			app.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
195	}
196	for _, tt := range tests {
197		t.Run(tt.name, func(t *testing.T) {
198			f := newTokenFixture(t)
199			if tt.want == nil {
200				f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
201			}
202			got, err := f.tokens.Create(ctx, tt.principal, tt.params)
203			if tt.want != nil {
204				assert.ErrorIs(t, err, tt.want)
205				assert.Empty(t, got.Secret)
206				return
207			}
208			require.NoError(t, err)
209			assert.NotEmpty(t, got.Secret)
210		})
211	}
212}
213
214func TestTokensListRevoke(t *testing.T) {
215	ctx := context.Background()
216	tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
217	writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)})
218
219	t.Run("list for the admin token and root", func(t *testing.T) {
220		f := newTokenFixture(t)
221		want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}}
222		f.store.EXPECT().List(ctx).Return(want, nil).Twice()
223		for _, p := range []domain.Principal{domain.Root(), tokenAdmin} {
224			got, err := f.tokens.List(ctx, p)
225			require.NoError(t, err)
226			assert.Equal(t, want, got)
227		}
228		_, err := f.tokens.List(ctx, writer)
229		assert.ErrorIs(t, err, domain.ErrForbidden)
230		_, err = f.tokens.List(ctx, domain.Anonymous())
231		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
232	})
233
234	t.Run("revoke", func(t *testing.T) {
235		f := newTokenFixture(t)
236		f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Twice()
237		f.store.EXPECT().Get(ctx, "zz").Return(domain.Token{}, domain.ErrNotFound).Once()
238		f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice()
239		require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a"))
240		require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a"))
241		assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound)
242		assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden)
243		assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated)
244	})
245}
246
247// GO-003b: revoking a token with owner rights signs every browser out (the
248// session epoch is bumped); revoking any other token does not.
249func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) {
250	ctx := context.Background()
251	ownerTok := domain.Token{ID: "own", Scopes: domain.AllScopes()}
252
253	for name, tc := range map[string]struct {
254		tok    domain.Token
255		by     domain.Principal
256		bumped bool
257	}{
258		"every scope, by root":         {ownerTok, domain.Root(), true},
259		"every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true},
260		"one scope short":              {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false},
261		"repo list":                    {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false},
262	} {
263		t.Run(name, func(t *testing.T) {
264			f := newTokenFixture(t)
265			f.store.EXPECT().Get(ctx, tc.tok.ID).Return(tc.tok, nil)
266			f.store.EXPECT().Revoke(ctx, tc.tok.ID).Return(nil)
267			if tc.bumped {
268				f.sessions.EXPECT().BumpEpoch(ctx).Return(1, nil).Once()
269			}
270			require.NoError(t, f.tokens.Revoke(ctx, tc.by, tc.tok.ID))
271		})
272	}
273
274	t.Run("a revoke that fails bumps nothing", func(t *testing.T) {
275		f := newTokenFixture(t)
276		f.store.EXPECT().Get(ctx, "own").Return(ownerTok, nil)
277		f.store.EXPECT().Revoke(ctx, "own").Return(errors.New("kv down"))
278		assert.Error(t, f.tokens.Revoke(ctx, domain.Root(), "own"))
279	})
280
281	t.Run("a token out of reach is not revoked and bumps nothing", func(t *testing.T) {
282		f := newTokenFixture(t)
283		limited := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)})
284		f.store.EXPECT().Get(ctx, "own").Return(ownerTok, nil)
285		assert.ErrorIs(t, f.tokens.Revoke(ctx, limited, "own"), domain.ErrNotFound)
286	})
287
288	t.Run("a failure to sign out is reported once the token is revoked", func(t *testing.T) {
289		f := newTokenFixture(t)
290		f.store.EXPECT().Get(ctx, "own").Return(ownerTok, nil)
291		f.store.EXPECT().Revoke(ctx, "own").Return(nil)
292		f.sessions.EXPECT().BumpEpoch(ctx).Return(0, errors.New("kv down"))
293		assert.ErrorContains(t, f.tokens.Revoke(ctx, domain.Root(), "own"), "signing out")
294	})
295}
296
297// A token admin sees and revokes only the tokens it could have created: the
298// no-escalation rule of Create, applied to scopes, repositories and expiry.
299func TestTokensAdminReach(t *testing.T) {
300	ctx := context.Background()
301	exp := now.Add(48 * time.Hour)
302	admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal {
303		return domain.TokenPrincipal(domain.Token{
304			ID:        "caller",
305			Scopes:    append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...),
306			Repos:     repos,
307			ExpiresAt: expires,
308		})
309	}
310	tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token {
311		return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires}
312	}
313	rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite)
314	ab, a := names(t, "a", "b"), names(t, "a")
315
316	tests := []struct {
317		name   string
318		caller domain.Principal
319		target domain.Token
320		within bool
321	}{
322		{"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true},
323		{"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true},
324		{"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false},
325		{"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},
326		{"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false},
327		{"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false},
328		{"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true},
329		{"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true},
330		{"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false},
331		{"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false},
332		{"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true},
333		{"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true},
334	}
335	for _, tt := range tests {
336		t.Run("list: "+tt.name, func(t *testing.T) {
337			f := newTokenFixture(t)
338			other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{})
339			f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil)
340			got, err := f.tokens.List(ctx, tt.caller)
341			require.NoError(t, err)
342			if !tt.within {
343				// Only the token that is within reach of every caller shows.
344				assert.NotContains(t, got, tt.target)
345				return
346			}
347			assert.Contains(t, got, tt.target)
348		})
349		t.Run("revoke: "+tt.name, func(t *testing.T) {
350			f := newTokenFixture(t)
351			f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil)
352			if tt.within {
353				f.store.EXPECT().Revoke(ctx, "t").Return(nil)
354				require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t"))
355				return
356			}
357			// Not leaked: the same answer as for a token that does not exist, and nothing is deleted.
358			assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound)
359		})
360	}
361
362	t.Run("root lists and revokes everything", func(t *testing.T) {
363		f := newTokenFixture(t)
364		all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)}
365		f.store.EXPECT().List(ctx).Return(all, nil)
366		got, err := f.tokens.List(ctx, domain.Root())
367		require.NoError(t, err)
368		assert.Equal(t, all, got)
369		f.store.EXPECT().Get(ctx, "x").Return(all[0], nil)
370		f.store.EXPECT().Revoke(ctx, "x").Return(nil)     // root has no limits
371		f.sessions.EXPECT().BumpEpoch(ctx).Return(1, nil) // "x" has owner rights
372		require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x"))
373	})
374
375	t.Run("an unknown id is not found for everyone", func(t *testing.T) {
376		f := newTokenFixture(t)
377		f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound)
378		assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound)
379	})
380
381	t.Run("a store failure on the lookup is returned", func(t *testing.T) {
382		f := newTokenFixture(t)
383		boom := errors.New("boom")
384		f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom)
385		assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom)
386	})
387}
388
389func TestAuthRevalidate(t *testing.T) {
390	ctx := context.Background()
391	p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)})
392
393	t.Run("root is always valid", func(t *testing.T) {
394		f := newTokenFixture(t)
395		require.NoError(t, f.auth.Revalidate(ctx, domain.Root()))
396	})
397	t.Run("anonymous is not", func(t *testing.T) {
398		f := newTokenFixture(t)
399		assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated)
400	})
401	t.Run("a stored token that exists", func(t *testing.T) {
402		f := newTokenFixture(t)
403		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil)
404		require.NoError(t, f.auth.Revalidate(ctx, p))
405	})
406	t.Run("revoked", func(t *testing.T) {
407		f := newTokenFixture(t)
408		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound)
409		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
410	})
411	t.Run("expired", func(t *testing.T) {
412		f := newTokenFixture(t)
413		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil)
414		assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated)
415	})
416	t.Run("a store failure is an error, not a pass", func(t *testing.T) {
417		f := newTokenFixture(t)
418		boom := errors.New("boom")
419		f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom)
420		err := f.auth.Revalidate(ctx, p)
421		require.ErrorIs(t, err, boom)
422		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
423	})
424}
425
426func TestAuthenticate(t *testing.T) {
427	ctx := context.Background()
428	secret := "ermite_stored-secret"
429	hash := domain.HashTokenSecret(secret)
430	tok := domain.Token{
431		ID: "t1", Hash: hash, Scopes: scopes(domain.ScopeRepoWrite), Repos: names(t, "a"),
432		ExpiresAt: now.Add(time.Hour),
433	}
434
435	t.Run("root", func(t *testing.T) {
436		f := newTokenFixture(t)
437		p, err := f.auth.Authenticate(ctx, rootSecret)
438		require.NoError(t, err)
439		assert.True(t, p.IsRoot())
440	})
441
442	t.Run("empty and unknown secrets", func(t *testing.T) {
443		f := newTokenFixture(t)
444		_, err := f.auth.Authenticate(ctx, "")
445		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
446		f.store.EXPECT().FindByHash(ctx, domain.HashTokenSecret("nope")).Return(domain.Token{}, domain.ErrNotFound)
447		_, err = f.auth.Authenticate(ctx, "nope")
448		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
449	})
450
451	t.Run("stored token first use touches", func(t *testing.T) {
452		f := newTokenFixture(t)
453		f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
454		f.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
455		p, err := f.auth.Authenticate(ctx, secret)
456		require.NoError(t, err)
457		assert.Equal(t, "t1", p.TokenID())
458		assert.True(t, p.Allows(domain.ScopeRepoWrite, name(t, "a")))
459		assert.False(t, p.Allows(domain.ScopeRepoWrite, name(t, "b")))
460	})
461
462	t.Run("touch at most once a minute", func(t *testing.T) {
463		f := newTokenFixture(t)
464		recent := tok
465		recent.LastUsedAt = now.Add(-30 * time.Second)
466		f.store.EXPECT().FindByHash(ctx, hash).Return(recent, nil)
467		_, err := f.auth.Authenticate(ctx, secret) // no Touch expected
468		require.NoError(t, err)
469
470		f2 := newTokenFixture(t)
471		old := tok
472		old.LastUsedAt = now.Add(-time.Minute)
473		f2.store.EXPECT().FindByHash(ctx, hash).Return(old, nil)
474		f2.store.EXPECT().Touch(ctx, "t1", now).Return(nil)
475		_, err = f2.auth.Authenticate(ctx, secret)
476		require.NoError(t, err)
477	})
478
479	t.Run("touch failure does not fail authentication", func(t *testing.T) {
480		f := newTokenFixture(t)
481		f.store.EXPECT().FindByHash(ctx, hash).Return(tok, nil)
482		f.store.EXPECT().Touch(ctx, "t1", now).Return(errors.New("disk"))
483		_, err := f.auth.Authenticate(ctx, secret)
484		require.NoError(t, err)
485	})
486
487	t.Run("expired", func(t *testing.T) {
488		f := newTokenFixture(t)
489		exp := tok
490		exp.ExpiresAt = now
491		f.store.EXPECT().FindByHash(ctx, hash).Return(exp, nil)
492		_, err := f.auth.Authenticate(ctx, secret)
493		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
494	})
495
496	t.Run("store error is not an authentication failure", func(t *testing.T) {
497		f := newTokenFixture(t)
498		boom := errors.New("boom")
499		f.store.EXPECT().FindByHash(ctx, hash).Return(domain.Token{}, boom)
500		_, err := f.auth.Authenticate(ctx, secret)
501		assert.ErrorIs(t, err, boom)
502		assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
503	})
504}

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 44

↑↓ moveenter openesc close