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}