feat(app): limit token list and revoke to the caller's reach, add Auth.Revalidate
List returns and Revoke accepts only tokens within the caller's scopes, repositories and expiry (the no-escalation rule of Create); root sees all. Revoking a more powerful token answers ErrNotFound. TokenStore gains Get so the use cases can look a token up by id; Auth.Revalidate checks that a stored token still exists and has not expired.
- commit
- 857b2f46b264866b889bbff9293d372d3b16f071
- author
- brice · · 2026-10-10 08:11 UTC
- parent
- 71acbb2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +283 -8
M internal/adapters/kvstore/tokens.go +12 -0
| @@ -69,6 +69,18 @@ func (s *Tokens) Create(ctx context.Context, t domain.Token) error { | |||
| 69 | 69 | return nil | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | // Get reads token/<id>. | |
| 73 | + | func (s *Tokens) Get(ctx context.Context, id string) (domain.Token, error) { | |
| 74 | + | if !validTokenID(id) { | |
| 75 | + | return domain.Token{}, domain.ErrNotFound | |
| 76 | + | } | |
| 77 | + | item, ok := s.db.Get(ctx, tokenKey(id)) | |
| 78 | + | if !ok { | |
| 79 | + | return domain.Token{}, domain.ErrNotFound | |
| 80 | + | } | |
| 81 | + | return decodeToken(item.Value) | |
| 82 | + | } | |
| 83 | + | ||
| 72 | 84 | // FindByHash resolves the hash index, then reads the token. | |
| 73 | 85 | func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) { | |
| 74 | 86 | snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree | |
| @@ -69,6 +69,18 @@ func (s *Tokens) Create(ctx context.Context, t domain.Token) error { | |||||
| 69 | return nil | 69 | return nil | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | + | // Get reads token/<id>. | |||
| 73 | + | func (s *Tokens) Get(ctx context.Context, id string) (domain.Token, error) { | |||
| 74 | + | if !validTokenID(id) { | |||
| 75 | + | return domain.Token{}, domain.ErrNotFound | |||
| 76 | + | } | |||
| 77 | + | item, ok := s.db.Get(ctx, tokenKey(id)) | |||
| 78 | + | if !ok { | |||
| 79 | + | return domain.Token{}, domain.ErrNotFound | |||
| 80 | + | } | |||
| 81 | + | return decodeToken(item.Value) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 72 | // FindByHash resolves the hash index, then reads the token. | 84 | // FindByHash resolves the hash index, then reads the token. | ||
| 73 | func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) { | 85 | func (s *Tokens) FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) { | ||
| 74 | snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree | 86 | snap := s.db.Snapshot(ctx) // one snapshot: the index and the token agree | ||
M internal/adapters/kvstore/tokens_test.go +19 -0
| @@ -117,6 +117,25 @@ func TestTokensRevokeKeepsIndexConsistent(t *testing.T) { | |||
| 117 | 117 | assert.Len(t, list, 2) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | + | func TestTokensGet(t *testing.T) { | |
| 121 | + | ctx := context.Background() | |
| 122 | + | ts := openStore(t, t.TempDir()).Tokens() | |
| 123 | + | tok := newToken(t, "t1", "s1") | |
| 124 | + | require.NoError(t, ts.Create(ctx, tok)) | |
| 125 | + | ||
| 126 | + | got, err := ts.Get(ctx, "t1") | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | assert.Equal(t, tok, got) | |
| 129 | + | ||
| 130 | + | for _, id := range []string{"gone", "", "../t1"} { | |
| 131 | + | _, err = ts.Get(ctx, id) | |
| 132 | + | assert.ErrorIs(t, err, domain.ErrNotFound, id) | |
| 133 | + | } | |
| 134 | + | require.NoError(t, ts.Revoke(ctx, "t1")) | |
| 135 | + | _, err = ts.Get(ctx, "t1") | |
| 136 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 137 | + | } | |
| 138 | + | ||
| 120 | 139 | func TestTokensTouch(t *testing.T) { | |
| 121 | 140 | ctx := context.Background() | |
| 122 | 141 | s := openStore(t, t.TempDir()) | |
| @@ -117,6 +117,25 @@ func TestTokensRevokeKeepsIndexConsistent(t *testing.T) { | |||||
| 117 | assert.Len(t, list, 2) | 117 | assert.Len(t, list, 2) | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | + | func TestTokensGet(t *testing.T) { | |||
| 121 | + | ctx := context.Background() | |||
| 122 | + | ts := openStore(t, t.TempDir()).Tokens() | |||
| 123 | + | tok := newToken(t, "t1", "s1") | |||
| 124 | + | require.NoError(t, ts.Create(ctx, tok)) | |||
| 125 | + | ||||
| 126 | + | got, err := ts.Get(ctx, "t1") | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | assert.Equal(t, tok, got) | |||
| 129 | + | ||||
| 130 | + | for _, id := range []string{"gone", "", "../t1"} { | |||
| 131 | + | _, err = ts.Get(ctx, id) | |||
| 132 | + | assert.ErrorIs(t, err, domain.ErrNotFound, id) | |||
| 133 | + | } | |||
| 134 | + | require.NoError(t, ts.Revoke(ctx, "t1")) | |||
| 135 | + | _, err = ts.Get(ctx, "t1") | |||
| 136 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 120 | func TestTokensTouch(t *testing.T) { | 139 | func TestTokensTouch(t *testing.T) { | ||
| 121 | ctx := context.Background() | 140 | ctx := context.Background() | ||
| 122 | s := openStore(t, t.TempDir()) | 141 | s := openStore(t, t.TempDir()) | ||
M internal/app/tokens.go +51 -4
| @@ -143,7 +143,9 @@ func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) { | |||
| 143 | 143 | return tok, nil | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | - | // checkNoEscalation enforces that tok is within the rights of its creator. | |
| 146 | + | // checkNoEscalation enforces that tok is within the rights of p: every scope | |
| 147 | + | // held by p, the repositories within its list and the expiry not beyond its | |
| 148 | + | // own. Root has no limits. | |
| 147 | 149 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |
| 148 | 150 | if p.IsRoot() { | |
| 149 | 151 | return nil | |
| @@ -171,20 +173,40 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||
| 171 | 173 | return nil | |
| 172 | 174 | } | |
| 173 | 175 | ||
| 174 | - | // List returns the stored tokens, oldest first. It needs token:admin. | |
| 176 | + | // List returns the stored tokens the principal could have created, oldest | |
| 177 | + | // first: those whose scopes, repositories and expiry lie within its own (the | |
| 178 | + | // no-escalation rule of Create). Root sees every token. It needs token:admin. | |
| 175 | 179 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | |
| 176 | 180 | if err := requireTokenAdmin(p); err != nil { | |
| 177 | 181 | return nil, err | |
| 178 | 182 | } | |
| 179 | - | return t.store.List(ctx) | |
| 183 | + | all, err := t.store.List(ctx) | |
| 184 | + | if err != nil { | |
| 185 | + | return nil, err | |
| 186 | + | } | |
| 187 | + | if p.IsRoot() { | |
| 188 | + | return all, nil | |
| 189 | + | } | |
| 190 | + | return slices.DeleteFunc(all, func(tok domain.Token) bool { return checkNoEscalation(p, tok) != nil }), nil | |
| 180 | 191 | } | |
| 181 | 192 | ||
| 182 | 193 | // Revoke deletes a stored token; it stops working at once. It needs | |
| 183 | - | // token:admin and returns domain.ErrNotFound for an unknown id. | |
| 194 | + | // token:admin and returns domain.ErrNotFound for an unknown id, and also for | |
| 195 | + | // a token more powerful than the principal (the no-escalation rule of | |
| 196 | + | // Create), so that it does not show itself. Root may revoke any token. | |
| 184 | 197 | func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error { | |
| 185 | 198 | if err := requireTokenAdmin(p); err != nil { | |
| 186 | 199 | return err | |
| 187 | 200 | } | |
| 201 | + | if !p.IsRoot() { | |
| 202 | + | tok, err := t.store.Get(ctx, id) | |
| 203 | + | if err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | if checkNoEscalation(p, tok) != nil { | |
| 207 | + | return domain.ErrNotFound | |
| 208 | + | } | |
| 209 | + | } | |
| 188 | 210 | if err := t.store.Revoke(ctx, id); err != nil { | |
| 189 | 211 | return err | |
| 190 | 212 | } | |
| @@ -231,6 +253,31 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo | |||
| 231 | 253 | return &Auth{root: rootHash, tokens: tokens, clock: clock} | |
| 232 | 254 | } | |
| 233 | 255 | ||
| 256 | + | // Revalidate checks that the holder of a principal obtained earlier is still | |
| 257 | + | // authorized: root always is; a stored token must still exist and not have | |
| 258 | + | // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the | |
| 259 | + | // store as it is, so that callers fail closed. It is for long operations that | |
| 260 | + | // outlive the request that authenticated them, such as a push. | |
| 261 | + | func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error { | |
| 262 | + | if p.IsRoot() { | |
| 263 | + | return nil | |
| 264 | + | } | |
| 265 | + | if p.IsAnonymous() { | |
| 266 | + | return domain.ErrUnauthenticated | |
| 267 | + | } | |
| 268 | + | tok, err := a.tokens.Get(ctx, p.TokenID()) | |
| 269 | + | if errors.Is(err, domain.ErrNotFound) { | |
| 270 | + | return domain.ErrUnauthenticated | |
| 271 | + | } | |
| 272 | + | if err != nil { | |
| 273 | + | return fmt.Errorf("find token: %w", err) | |
| 274 | + | } | |
| 275 | + | if tok.Expired(a.clock.Now().UTC()) { | |
| 276 | + | return domain.ErrUnauthenticated | |
| 277 | + | } | |
| 278 | + | return nil | |
| 279 | + | } | |
| 280 | + | ||
| 234 | 281 | // Authenticate resolves a token secret to its principal: root when it is the | |
| 235 | 282 | // admin token (constant-time comparison of the hashes), else the stored token | |
| 236 | 283 | // with that hash if it has not expired. Any other secret, including the empty | |
| @@ -143,7 +143,9 @@ func buildToken(params CreateTokenParams, now time.Time) (domain.Token, error) { | |||||
| 143 | return tok, nil | 143 | return tok, nil | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | - | // checkNoEscalation enforces that tok is within the rights of its creator. | 146 | + | // checkNoEscalation enforces that tok is within the rights of p: every scope |
| 147 | + | // held by p, the repositories within its list and the expiry not beyond its | |||
| 148 | + | // own. Root has no limits. | |||
| 147 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | 149 | func checkNoEscalation(p domain.Principal, tok domain.Token) error { | ||
| 148 | if p.IsRoot() { | 150 | if p.IsRoot() { | ||
| 149 | return nil | 151 | return nil | ||
| @@ -171,20 +173,40 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error { | |||||
| 171 | return nil | 173 | return nil | ||
| 172 | } | 174 | } | ||
| 173 | 175 | ||||
| 174 | - | // List returns the stored tokens, oldest first. It needs token:admin. | 176 | + | // List returns the stored tokens the principal could have created, oldest |
| 177 | + | // first: those whose scopes, repositories and expiry lie within its own (the | |||
| 178 | + | // no-escalation rule of Create). Root sees every token. It needs token:admin. | |||
| 175 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | 179 | func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) { | ||
| 176 | if err := requireTokenAdmin(p); err != nil { | 180 | if err := requireTokenAdmin(p); err != nil { | ||
| 177 | return nil, err | 181 | return nil, err | ||
| 178 | } | 182 | } | ||
| 179 | - | return t.store.List(ctx) | 183 | + | all, err := t.store.List(ctx) |
| 184 | + | if err != nil { | |||
| 185 | + | return nil, err | |||
| 186 | + | } | |||
| 187 | + | if p.IsRoot() { | |||
| 188 | + | return all, nil | |||
| 189 | + | } | |||
| 190 | + | return slices.DeleteFunc(all, func(tok domain.Token) bool { return checkNoEscalation(p, tok) != nil }), nil | |||
| 180 | } | 191 | } | ||
| 181 | 192 | ||||
| 182 | // Revoke deletes a stored token; it stops working at once. It needs | 193 | // Revoke deletes a stored token; it stops working at once. It needs | ||
| 183 | - | // token:admin and returns domain.ErrNotFound for an unknown id. | 194 | + | // token:admin and returns domain.ErrNotFound for an unknown id, and also for |
| 195 | + | // a token more powerful than the principal (the no-escalation rule of | |||
| 196 | + | // Create), so that it does not show itself. Root may revoke any token. | |||
| 184 | func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error { | 197 | func (t *Tokens) Revoke(ctx context.Context, p domain.Principal, id string) error { | ||
| 185 | if err := requireTokenAdmin(p); err != nil { | 198 | if err := requireTokenAdmin(p); err != nil { | ||
| 186 | return err | 199 | return err | ||
| 187 | } | 200 | } | ||
| 201 | + | if !p.IsRoot() { | |||
| 202 | + | tok, err := t.store.Get(ctx, id) | |||
| 203 | + | if err != nil { | |||
| 204 | + | return err | |||
| 205 | + | } | |||
| 206 | + | if checkNoEscalation(p, tok) != nil { | |||
| 207 | + | return domain.ErrNotFound | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 188 | if err := t.store.Revoke(ctx, id); err != nil { | 210 | if err := t.store.Revoke(ctx, id); err != nil { | ||
| 189 | return err | 211 | return err | ||
| 190 | } | 212 | } | ||
| @@ -231,6 +253,31 @@ func NewAuth(rootHash domain.TokenHash, tokens ports.TokenStore, clock ports.Clo | |||||
| 231 | return &Auth{root: rootHash, tokens: tokens, clock: clock} | 253 | return &Auth{root: rootHash, tokens: tokens, clock: clock} | ||
| 232 | } | 254 | } | ||
| 233 | 255 | ||||
| 256 | + | // Revalidate checks that the holder of a principal obtained earlier is still | |||
| 257 | + | // authorized: root always is; a stored token must still exist and not have | |||
| 258 | + | // expired. It gives domain.ErrUnauthenticated otherwise, and any error of the | |||
| 259 | + | // store as it is, so that callers fail closed. It is for long operations that | |||
| 260 | + | // outlive the request that authenticated them, such as a push. | |||
| 261 | + | func (a *Auth) Revalidate(ctx context.Context, p domain.Principal) error { | |||
| 262 | + | if p.IsRoot() { | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | if p.IsAnonymous() { | |||
| 266 | + | return domain.ErrUnauthenticated | |||
| 267 | + | } | |||
| 268 | + | tok, err := a.tokens.Get(ctx, p.TokenID()) | |||
| 269 | + | if errors.Is(err, domain.ErrNotFound) { | |||
| 270 | + | return domain.ErrUnauthenticated | |||
| 271 | + | } | |||
| 272 | + | if err != nil { | |||
| 273 | + | return fmt.Errorf("find token: %w", err) | |||
| 274 | + | } | |||
| 275 | + | if tok.Expired(a.clock.Now().UTC()) { | |||
| 276 | + | return domain.ErrUnauthenticated | |||
| 277 | + | } | |||
| 278 | + | return nil | |||
| 279 | + | } | |||
| 280 | + | ||||
| 234 | // Authenticate resolves a token secret to its principal: root when it is the | 281 | // Authenticate resolves a token secret to its principal: root when it is the | ||
| 235 | // admin token (constant-time comparison of the hashes), else the stored token | 282 | // admin token (constant-time comparison of the hashes), else the stored token | ||
| 236 | // with that hash if it has not expired. Any other secret, including the empty | 283 | // with that hash if it has not expired. Any other secret, including the empty | ||
M internal/app/tokens_test.go +133 -4
| @@ -196,12 +196,12 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||
| 196 | 196 | ||
| 197 | 197 | func TestTokensListRevoke(t *testing.T) { | |
| 198 | 198 | ctx := context.Background() | |
| 199 | - | tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)}) | |
| 199 | + | tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)}) | |
| 200 | 200 | writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 201 | 201 | ||
| 202 | - | t.Run("list", func(t *testing.T) { | |
| 202 | + | t.Run("list for the admin token and root", func(t *testing.T) { | |
| 203 | 203 | f := newTokenFixture(t) | |
| 204 | - | want := []domain.Token{{ID: "a"}} | |
| 204 | + | want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}} | |
| 205 | 205 | f.store.EXPECT().List(ctx).Return(want, nil).Twice() | |
| 206 | 206 | for _, p := range []domain.Principal{domain.Root(), tokenAdmin} { | |
| 207 | 207 | got, err := f.tokens.List(ctx, p) | |
| @@ -216,15 +216,144 @@ func TestTokensListRevoke(t *testing.T) { | |||
| 216 | 216 | ||
| 217 | 217 | t.Run("revoke", func(t *testing.T) { | |
| 218 | 218 | f := newTokenFixture(t) | |
| 219 | - | f.store.EXPECT().Revoke(ctx, "a").Return(nil).Once() | |
| 219 | + | f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Once() | |
| 220 | + | f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice() | |
| 220 | 221 | f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once() | |
| 221 | 222 | require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a")) | |
| 223 | + | require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a")) | |
| 222 | 224 | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound) | |
| 223 | 225 | assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden) | |
| 224 | 226 | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated) | |
| 225 | 227 | }) | |
| 226 | 228 | } | |
| 227 | 229 | ||
| 230 | + | // A token admin sees and revokes only the tokens it could have created: the | |
| 231 | + | // no-escalation rule of Create, applied to scopes, repositories and expiry. | |
| 232 | + | func TestTokensAdminReach(t *testing.T) { | |
| 233 | + | ctx := context.Background() | |
| 234 | + | exp := now.Add(48 * time.Hour) | |
| 235 | + | admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal { | |
| 236 | + | return domain.TokenPrincipal(domain.Token{ | |
| 237 | + | ID: "caller", | |
| 238 | + | Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...), | |
| 239 | + | Repos: repos, | |
| 240 | + | ExpiresAt: expires, | |
| 241 | + | }) | |
| 242 | + | } | |
| 243 | + | tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token { | |
| 244 | + | return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires} | |
| 245 | + | } | |
| 246 | + | rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite) | |
| 247 | + | ab, a := names(t, "a", "b"), names(t, "a") | |
| 248 | + | ||
| 249 | + | tests := []struct { | |
| 250 | + | name string | |
| 251 | + | caller domain.Principal | |
| 252 | + | target domain.Token | |
| 253 | + | within bool | |
| 254 | + | }{ | |
| 255 | + | {"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true}, | |
| 256 | + | {"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true}, | |
| 257 | + | {"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false}, | |
| 258 | + | {"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}, | |
| 259 | + | {"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false}, | |
| 260 | + | {"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false}, | |
| 261 | + | {"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true}, | |
| 262 | + | {"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true}, | |
| 263 | + | {"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false}, | |
| 264 | + | {"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false}, | |
| 265 | + | {"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true}, | |
| 266 | + | {"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true}, | |
| 267 | + | } | |
| 268 | + | for _, tt := range tests { | |
| 269 | + | t.Run("list: "+tt.name, func(t *testing.T) { | |
| 270 | + | f := newTokenFixture(t) | |
| 271 | + | other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{}) | |
| 272 | + | f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil) | |
| 273 | + | got, err := f.tokens.List(ctx, tt.caller) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | if !tt.within { | |
| 276 | + | // Only the token that is within reach of every caller shows. | |
| 277 | + | assert.NotContains(t, got, tt.target) | |
| 278 | + | return | |
| 279 | + | } | |
| 280 | + | assert.Contains(t, got, tt.target) | |
| 281 | + | }) | |
| 282 | + | t.Run("revoke: "+tt.name, func(t *testing.T) { | |
| 283 | + | f := newTokenFixture(t) | |
| 284 | + | f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil) | |
| 285 | + | if tt.within { | |
| 286 | + | f.store.EXPECT().Revoke(ctx, "t").Return(nil) | |
| 287 | + | require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t")) | |
| 288 | + | return | |
| 289 | + | } | |
| 290 | + | // Not leaked: the same answer as for a token that does not exist, and nothing is deleted. | |
| 291 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound) | |
| 292 | + | }) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | t.Run("root lists and revokes everything", func(t *testing.T) { | |
| 296 | + | f := newTokenFixture(t) | |
| 297 | + | all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)} | |
| 298 | + | f.store.EXPECT().List(ctx).Return(all, nil) | |
| 299 | + | got, err := f.tokens.List(ctx, domain.Root()) | |
| 300 | + | require.NoError(t, err) | |
| 301 | + | assert.Equal(t, all, got) | |
| 302 | + | f.store.EXPECT().Revoke(ctx, "x").Return(nil) // no Get: root has no limits | |
| 303 | + | require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x")) | |
| 304 | + | }) | |
| 305 | + | ||
| 306 | + | t.Run("an unknown id is not found for everyone", func(t *testing.T) { | |
| 307 | + | f := newTokenFixture(t) | |
| 308 | + | f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound) | |
| 309 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound) | |
| 310 | + | }) | |
| 311 | + | ||
| 312 | + | t.Run("a store failure on the lookup is returned", func(t *testing.T) { | |
| 313 | + | f := newTokenFixture(t) | |
| 314 | + | boom := errors.New("boom") | |
| 315 | + | f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom) | |
| 316 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom) | |
| 317 | + | }) | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestAuthRevalidate(t *testing.T) { | |
| 321 | + | ctx := context.Background() | |
| 322 | + | p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 323 | + | ||
| 324 | + | t.Run("root is always valid", func(t *testing.T) { | |
| 325 | + | f := newTokenFixture(t) | |
| 326 | + | require.NoError(t, f.auth.Revalidate(ctx, domain.Root())) | |
| 327 | + | }) | |
| 328 | + | t.Run("anonymous is not", func(t *testing.T) { | |
| 329 | + | f := newTokenFixture(t) | |
| 330 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated) | |
| 331 | + | }) | |
| 332 | + | t.Run("a stored token that exists", func(t *testing.T) { | |
| 333 | + | f := newTokenFixture(t) | |
| 334 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil) | |
| 335 | + | require.NoError(t, f.auth.Revalidate(ctx, p)) | |
| 336 | + | }) | |
| 337 | + | t.Run("revoked", func(t *testing.T) { | |
| 338 | + | f := newTokenFixture(t) | |
| 339 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound) | |
| 340 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated) | |
| 341 | + | }) | |
| 342 | + | t.Run("expired", func(t *testing.T) { | |
| 343 | + | f := newTokenFixture(t) | |
| 344 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil) | |
| 345 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated) | |
| 346 | + | }) | |
| 347 | + | t.Run("a store failure is an error, not a pass", func(t *testing.T) { | |
| 348 | + | f := newTokenFixture(t) | |
| 349 | + | boom := errors.New("boom") | |
| 350 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom) | |
| 351 | + | err := f.auth.Revalidate(ctx, p) | |
| 352 | + | require.ErrorIs(t, err, boom) | |
| 353 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |
| 354 | + | }) | |
| 355 | + | } | |
| 356 | + | ||
| 228 | 357 | func TestAuthenticate(t *testing.T) { | |
| 229 | 358 | ctx := context.Background() | |
| 230 | 359 | secret := "ermite_stored-secret" | |
| @@ -196,12 +196,12 @@ func TestTokensCreateNoEscalation(t *testing.T) { | |||||
| 196 | 196 | ||||
| 197 | func TestTokensListRevoke(t *testing.T) { | 197 | func TestTokensListRevoke(t *testing.T) { | ||
| 198 | ctx := context.Background() | 198 | ctx := context.Background() | ||
| 199 | - | tokenAdmin := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeTokenAdmin)}) | 199 | + | tokenAdmin := domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: scopes(domain.ScopeTokenAdmin)}) |
| 200 | writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}) | 200 | writer := domain.TokenPrincipal(domain.Token{Scopes: scopes(domain.ScopeRepoWrite)}) | ||
| 201 | 201 | ||||
| 202 | - | t.Run("list", func(t *testing.T) { | 202 | + | t.Run("list for the admin token and root", func(t *testing.T) { |
| 203 | f := newTokenFixture(t) | 203 | f := newTokenFixture(t) | ||
| 204 | - | want := []domain.Token{{ID: "a"}} | 204 | + | want := []domain.Token{{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}} |
| 205 | f.store.EXPECT().List(ctx).Return(want, nil).Twice() | 205 | f.store.EXPECT().List(ctx).Return(want, nil).Twice() | ||
| 206 | for _, p := range []domain.Principal{domain.Root(), tokenAdmin} { | 206 | for _, p := range []domain.Principal{domain.Root(), tokenAdmin} { | ||
| 207 | got, err := f.tokens.List(ctx, p) | 207 | got, err := f.tokens.List(ctx, p) | ||
| @@ -216,15 +216,144 @@ func TestTokensListRevoke(t *testing.T) { | |||||
| 216 | 216 | ||||
| 217 | t.Run("revoke", func(t *testing.T) { | 217 | t.Run("revoke", func(t *testing.T) { | ||
| 218 | f := newTokenFixture(t) | 218 | f := newTokenFixture(t) | ||
| 219 | - | f.store.EXPECT().Revoke(ctx, "a").Return(nil).Once() | 219 | + | f.store.EXPECT().Get(ctx, "a").Return(domain.Token{ID: "a", Scopes: scopes(domain.ScopeTokenAdmin)}, nil).Once() |
| 220 | + | f.store.EXPECT().Revoke(ctx, "a").Return(nil).Twice() | |||
| 220 | f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once() | 221 | f.store.EXPECT().Revoke(ctx, "zz").Return(domain.ErrNotFound).Once() | ||
| 221 | require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a")) | 222 | require.NoError(t, f.tokens.Revoke(ctx, tokenAdmin, "a")) | ||
| 223 | + | require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "a")) | |||
| 222 | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound) | 224 | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Root(), "zz"), domain.ErrNotFound) | ||
| 223 | assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden) | 225 | assert.ErrorIs(t, f.tokens.Revoke(ctx, writer, "a"), domain.ErrForbidden) | ||
| 224 | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated) | 226 | assert.ErrorIs(t, f.tokens.Revoke(ctx, domain.Anonymous(), "a"), domain.ErrUnauthenticated) | ||
| 225 | }) | 227 | }) | ||
| 226 | } | 228 | } | ||
| 227 | 229 | ||||
| 230 | + | // A token admin sees and revokes only the tokens it could have created: the | |||
| 231 | + | // no-escalation rule of Create, applied to scopes, repositories and expiry. | |||
| 232 | + | func TestTokensAdminReach(t *testing.T) { | |||
| 233 | + | ctx := context.Background() | |||
| 234 | + | exp := now.Add(48 * time.Hour) | |||
| 235 | + | admin := func(repos []domain.RepoName, expires time.Time, extra ...domain.Scope) domain.Principal { | |||
| 236 | + | return domain.TokenPrincipal(domain.Token{ | |||
| 237 | + | ID: "caller", | |||
| 238 | + | Scopes: append(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite), extra...), | |||
| 239 | + | Repos: repos, | |||
| 240 | + | ExpiresAt: expires, | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | tok := func(id string, sc []domain.Scope, repos []domain.RepoName, expires time.Time) domain.Token { | |||
| 244 | + | return domain.Token{ID: id, Scopes: sc, Repos: repos, ExpiresAt: expires} | |||
| 245 | + | } | |||
| 246 | + | rw := scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite) | |||
| 247 | + | ab, a := names(t, "a", "b"), names(t, "a") | |||
| 248 | + | ||||
| 249 | + | tests := []struct { | |||
| 250 | + | name string | |||
| 251 | + | caller domain.Principal | |||
| 252 | + | target domain.Token | |||
| 253 | + | within bool | |||
| 254 | + | }{ | |||
| 255 | + | {"fewer scopes", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead), nil, time.Time{}), true}, | |||
| 256 | + | {"the same rights", admin(ab, exp), tok("t", rw, ab, exp), true}, | |||
| 257 | + | {"a scope the caller lacks", admin(nil, time.Time{}), tok("t", scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), nil, time.Time{}), false}, | |||
| 258 | + | {"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}, | |||
| 259 | + | {"unlimited token, limited caller", admin(a, time.Time{}), tok("t", rw, nil, time.Time{}), false}, | |||
| 260 | + | {"repo outside the caller's list", admin(a, time.Time{}), tok("t", rw, ab, time.Time{}), false}, | |||
| 261 | + | {"repo inside the caller's list", admin(ab, time.Time{}), tok("t", rw, a, time.Time{}), true}, | |||
| 262 | + | {"limited token, unlimited caller", admin(nil, time.Time{}), tok("t", rw, ab, time.Time{}), true}, | |||
| 263 | + | {"never expires, caller expires", admin(nil, exp), tok("t", rw, nil, time.Time{}), false}, | |||
| 264 | + | {"expires later than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(time.Hour)), false}, | |||
| 265 | + | {"expires sooner than the caller", admin(nil, exp), tok("t", rw, nil, exp.Add(-time.Hour)), true}, | |||
| 266 | + | {"expiring token, caller never expires", admin(nil, time.Time{}), tok("t", rw, nil, exp), true}, | |||
| 267 | + | } | |||
| 268 | + | for _, tt := range tests { | |||
| 269 | + | t.Run("list: "+tt.name, func(t *testing.T) { | |||
| 270 | + | f := newTokenFixture(t) | |||
| 271 | + | other := tok("other", scopes(domain.ScopeRepoRead), nil, time.Time{}) | |||
| 272 | + | f.store.EXPECT().List(ctx).Return([]domain.Token{tt.target, other}, nil) | |||
| 273 | + | got, err := f.tokens.List(ctx, tt.caller) | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | if !tt.within { | |||
| 276 | + | // Only the token that is within reach of every caller shows. | |||
| 277 | + | assert.NotContains(t, got, tt.target) | |||
| 278 | + | return | |||
| 279 | + | } | |||
| 280 | + | assert.Contains(t, got, tt.target) | |||
| 281 | + | }) | |||
| 282 | + | t.Run("revoke: "+tt.name, func(t *testing.T) { | |||
| 283 | + | f := newTokenFixture(t) | |||
| 284 | + | f.store.EXPECT().Get(ctx, "t").Return(tt.target, nil) | |||
| 285 | + | if tt.within { | |||
| 286 | + | f.store.EXPECT().Revoke(ctx, "t").Return(nil) | |||
| 287 | + | require.NoError(t, f.tokens.Revoke(ctx, tt.caller, "t")) | |||
| 288 | + | return | |||
| 289 | + | } | |||
| 290 | + | // Not leaked: the same answer as for a token that does not exist, and nothing is deleted. | |||
| 291 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, tt.caller, "t"), domain.ErrNotFound) | |||
| 292 | + | }) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | t.Run("root lists and revokes everything", func(t *testing.T) { | |||
| 296 | + | f := newTokenFixture(t) | |||
| 297 | + | all := []domain.Token{tok("x", domain.AllScopes(), nil, time.Time{}), tok("y", scopes(domain.ScopeMRMerge), a, exp)} | |||
| 298 | + | f.store.EXPECT().List(ctx).Return(all, nil) | |||
| 299 | + | got, err := f.tokens.List(ctx, domain.Root()) | |||
| 300 | + | require.NoError(t, err) | |||
| 301 | + | assert.Equal(t, all, got) | |||
| 302 | + | f.store.EXPECT().Revoke(ctx, "x").Return(nil) // no Get: root has no limits | |||
| 303 | + | require.NoError(t, f.tokens.Revoke(ctx, domain.Root(), "x")) | |||
| 304 | + | }) | |||
| 305 | + | ||||
| 306 | + | t.Run("an unknown id is not found for everyone", func(t *testing.T) { | |||
| 307 | + | f := newTokenFixture(t) | |||
| 308 | + | f.store.EXPECT().Get(ctx, "nope").Return(domain.Token{}, domain.ErrNotFound) | |||
| 309 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "nope"), domain.ErrNotFound) | |||
| 310 | + | }) | |||
| 311 | + | ||||
| 312 | + | t.Run("a store failure on the lookup is returned", func(t *testing.T) { | |||
| 313 | + | f := newTokenFixture(t) | |||
| 314 | + | boom := errors.New("boom") | |||
| 315 | + | f.store.EXPECT().Get(ctx, "t").Return(domain.Token{}, boom) | |||
| 316 | + | assert.ErrorIs(t, f.tokens.Revoke(ctx, admin(nil, time.Time{}), "t"), boom) | |||
| 317 | + | }) | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestAuthRevalidate(t *testing.T) { | |||
| 321 | + | ctx := context.Background() | |||
| 322 | + | p := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: scopes(domain.ScopeRepoWrite)}) | |||
| 323 | + | ||||
| 324 | + | t.Run("root is always valid", func(t *testing.T) { | |||
| 325 | + | f := newTokenFixture(t) | |||
| 326 | + | require.NoError(t, f.auth.Revalidate(ctx, domain.Root())) | |||
| 327 | + | }) | |||
| 328 | + | t.Run("anonymous is not", func(t *testing.T) { | |||
| 329 | + | f := newTokenFixture(t) | |||
| 330 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, domain.Anonymous()), domain.ErrUnauthenticated) | |||
| 331 | + | }) | |||
| 332 | + | t.Run("a stored token that exists", func(t *testing.T) { | |||
| 333 | + | f := newTokenFixture(t) | |||
| 334 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now.Add(time.Hour)}, nil) | |||
| 335 | + | require.NoError(t, f.auth.Revalidate(ctx, p)) | |||
| 336 | + | }) | |||
| 337 | + | t.Run("revoked", func(t *testing.T) { | |||
| 338 | + | f := newTokenFixture(t) | |||
| 339 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, domain.ErrNotFound) | |||
| 340 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated) | |||
| 341 | + | }) | |||
| 342 | + | t.Run("expired", func(t *testing.T) { | |||
| 343 | + | f := newTokenFixture(t) | |||
| 344 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{ID: "t1", ExpiresAt: now}, nil) | |||
| 345 | + | assert.ErrorIs(t, f.auth.Revalidate(ctx, p), domain.ErrUnauthenticated) | |||
| 346 | + | }) | |||
| 347 | + | t.Run("a store failure is an error, not a pass", func(t *testing.T) { | |||
| 348 | + | f := newTokenFixture(t) | |||
| 349 | + | boom := errors.New("boom") | |||
| 350 | + | f.store.EXPECT().Get(ctx, "t1").Return(domain.Token{}, boom) | |||
| 351 | + | err := f.auth.Revalidate(ctx, p) | |||
| 352 | + | require.ErrorIs(t, err, boom) | |||
| 353 | + | assert.NotErrorIs(t, err, domain.ErrUnauthenticated) | |||
| 354 | + | }) | |||
| 355 | + | } | |||
| 356 | + | ||||
| 228 | func TestAuthenticate(t *testing.T) { | 357 | func TestAuthenticate(t *testing.T) { | ||
| 229 | ctx := context.Background() | 358 | ctx := context.Background() | ||
| 230 | secret := "ermite_stored-secret" | 359 | secret := "ermite_stored-secret" | ||
M internal/ports/mocks/TokenStore.go +66 -0
| @@ -171,6 +171,72 @@ func (_c *TokenStore_FindByHash_Call) RunAndReturn(run func(ctx context.Context, | |||
| 171 | 171 | return _c | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | // Get provides a mock function for the type TokenStore | |
| 175 | + | func (_mock *TokenStore) Get(ctx context.Context, id string) (domain.Token, error) { | |
| 176 | + | ret := _mock.Called(ctx, id) | |
| 177 | + | ||
| 178 | + | if len(ret) == 0 { | |
| 179 | + | panic("no return value specified for Get") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | var r0 domain.Token | |
| 183 | + | var r1 error | |
| 184 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Token, error)); ok { | |
| 185 | + | return returnFunc(ctx, id) | |
| 186 | + | } | |
| 187 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Token); ok { | |
| 188 | + | r0 = returnFunc(ctx, id) | |
| 189 | + | } else { | |
| 190 | + | r0 = ret.Get(0).(domain.Token) | |
| 191 | + | } | |
| 192 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 193 | + | r1 = returnFunc(ctx, id) | |
| 194 | + | } else { | |
| 195 | + | r1 = ret.Error(1) | |
| 196 | + | } | |
| 197 | + | return r0, r1 | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // TokenStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 201 | + | type TokenStore_Get_Call struct { | |
| 202 | + | *mock.Call | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Get is a helper method to define mock.On call | |
| 206 | + | // - ctx context.Context | |
| 207 | + | // - id string | |
| 208 | + | func (_e *TokenStore_Expecter) Get(ctx any, id any) *TokenStore_Get_Call { | |
| 209 | + | return &TokenStore_Get_Call{Call: _e.mock.On("Get", ctx, id)} | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *TokenStore_Get_Call) Run(run func(ctx context.Context, id string)) *TokenStore_Get_Call { | |
| 213 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 214 | + | var arg0 context.Context | |
| 215 | + | if args[0] != nil { | |
| 216 | + | arg0 = args[0].(context.Context) | |
| 217 | + | } | |
| 218 | + | var arg1 string | |
| 219 | + | if args[1] != nil { | |
| 220 | + | arg1 = args[1].(string) | |
| 221 | + | } | |
| 222 | + | run( | |
| 223 | + | arg0, | |
| 224 | + | arg1, | |
| 225 | + | ) | |
| 226 | + | }) | |
| 227 | + | return _c | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (_c *TokenStore_Get_Call) Return(token domain.Token, err error) *TokenStore_Get_Call { | |
| 231 | + | _c.Call.Return(token, err) | |
| 232 | + | return _c | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (_c *TokenStore_Get_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.Token, error)) *TokenStore_Get_Call { | |
| 236 | + | _c.Call.Return(run) | |
| 237 | + | return _c | |
| 238 | + | } | |
| 239 | + | ||
| 174 | 240 | // List provides a mock function for the type TokenStore | |
| 175 | 241 | func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) { | |
| 176 | 242 | ret := _mock.Called(ctx) | |
| @@ -171,6 +171,72 @@ func (_c *TokenStore_FindByHash_Call) RunAndReturn(run func(ctx context.Context, | |||||
| 171 | return _c | 171 | return _c | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | + | // Get provides a mock function for the type TokenStore | |||
| 175 | + | func (_mock *TokenStore) Get(ctx context.Context, id string) (domain.Token, error) { | |||
| 176 | + | ret := _mock.Called(ctx, id) | |||
| 177 | + | ||||
| 178 | + | if len(ret) == 0 { | |||
| 179 | + | panic("no return value specified for Get") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | var r0 domain.Token | |||
| 183 | + | var r1 error | |||
| 184 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Token, error)); ok { | |||
| 185 | + | return returnFunc(ctx, id) | |||
| 186 | + | } | |||
| 187 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Token); ok { | |||
| 188 | + | r0 = returnFunc(ctx, id) | |||
| 189 | + | } else { | |||
| 190 | + | r0 = ret.Get(0).(domain.Token) | |||
| 191 | + | } | |||
| 192 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 193 | + | r1 = returnFunc(ctx, id) | |||
| 194 | + | } else { | |||
| 195 | + | r1 = ret.Error(1) | |||
| 196 | + | } | |||
| 197 | + | return r0, r1 | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // TokenStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |||
| 201 | + | type TokenStore_Get_Call struct { | |||
| 202 | + | *mock.Call | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // Get is a helper method to define mock.On call | |||
| 206 | + | // - ctx context.Context | |||
| 207 | + | // - id string | |||
| 208 | + | func (_e *TokenStore_Expecter) Get(ctx any, id any) *TokenStore_Get_Call { | |||
| 209 | + | return &TokenStore_Get_Call{Call: _e.mock.On("Get", ctx, id)} | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (_c *TokenStore_Get_Call) Run(run func(ctx context.Context, id string)) *TokenStore_Get_Call { | |||
| 213 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 214 | + | var arg0 context.Context | |||
| 215 | + | if args[0] != nil { | |||
| 216 | + | arg0 = args[0].(context.Context) | |||
| 217 | + | } | |||
| 218 | + | var arg1 string | |||
| 219 | + | if args[1] != nil { | |||
| 220 | + | arg1 = args[1].(string) | |||
| 221 | + | } | |||
| 222 | + | run( | |||
| 223 | + | arg0, | |||
| 224 | + | arg1, | |||
| 225 | + | ) | |||
| 226 | + | }) | |||
| 227 | + | return _c | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (_c *TokenStore_Get_Call) Return(token domain.Token, err error) *TokenStore_Get_Call { | |||
| 231 | + | _c.Call.Return(token, err) | |||
| 232 | + | return _c | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func (_c *TokenStore_Get_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.Token, error)) *TokenStore_Get_Call { | |||
| 236 | + | _c.Call.Return(run) | |||
| 237 | + | return _c | |||
| 238 | + | } | |||
| 239 | + | ||||
| 174 | // List provides a mock function for the type TokenStore | 240 | // List provides a mock function for the type TokenStore | ||
| 175 | func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) { | 241 | func (_mock *TokenStore) List(ctx context.Context) ([]domain.Token, error) { | ||
| 176 | ret := _mock.Called(ctx) | 242 | ret := _mock.Called(ctx) | ||
M internal/ports/ports.go +2 -0
| @@ -96,6 +96,8 @@ type TokenStore interface { | |||
| 96 | 96 | // Create stores the token and its hash index in one write. It returns | |
| 97 | 97 | // domain.ErrAlreadyExists if the id or the hash is taken. | |
| 98 | 98 | Create(ctx context.Context, token domain.Token) error | |
| 99 | + | // Get returns the token with that id, or domain.ErrNotFound. | |
| 100 | + | Get(ctx context.Context, id string) (domain.Token, error) | |
| 99 | 101 | // FindByHash returns the token whose secret has that hash, or | |
| 100 | 102 | // domain.ErrNotFound. | |
| 101 | 103 | FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) | |
| @@ -96,6 +96,8 @@ type TokenStore interface { | |||||
| 96 | // Create stores the token and its hash index in one write. It returns | 96 | // Create stores the token and its hash index in one write. It returns | ||
| 97 | // domain.ErrAlreadyExists if the id or the hash is taken. | 97 | // domain.ErrAlreadyExists if the id or the hash is taken. | ||
| 98 | Create(ctx context.Context, token domain.Token) error | 98 | Create(ctx context.Context, token domain.Token) error | ||
| 99 | + | // Get returns the token with that id, or domain.ErrNotFound. | |||
| 100 | + | Get(ctx context.Context, id string) (domain.Token, error) | |||
| 99 | // FindByHash returns the token whose secret has that hash, or | 101 | // FindByHash returns the token whose secret has that hash, or | ||
| 100 | // domain.ErrNotFound. | 102 | // domain.ErrNotFound. | ||
| 101 | FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) | 103 | FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error) | ||