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}