1package tokenstore
2
3import (
4 "context"
5 "sync"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15// These tests verify the in-memory caching behavior of PassStore.
16// They directly manipulate the cache to test cache hit/miss logic
17// without requiring the actual pass binary.
18
19func newTestPassStore() *PassStore {
20 return &PassStore{
21 timeout: 10 * time.Second,
22 tokenCache: make(map[string]*cachedToken),
23 revokedSet: make(map[string]struct{}),
24 }
25}
26
27func TestPassStore_GetToken_CacheHit(t *testing.T) {
28 store := newTestPassStore()
29
30 // Pre-populate cache
31 expectedToken := &domain.Token{
32 ID: "test-id",
33 Subject: "test-subject",
34 Scopes: []string{"push", "pull"},
35 IssuedAt: time.Now(),
36 ExpiresAt: time.Now().Add(24 * time.Hour),
37 }
38 expectedJWT := "cached-jwt-token"
39
40 store.cacheMu.Lock()
41 store.tokenCache["test-subject"] = &cachedToken{
42 jwt: expectedJWT,
43 token: expectedToken,
44 }
45 store.cacheMu.Unlock()
46
47 // Should return cached value without calling pass
48 jwt, token, err := store.GetToken(context.Background(), "test-subject")
49
50 require.NoError(t, err)
51 assert.Equal(t, expectedJWT, jwt)
52 assert.Equal(t, expectedToken.ID, token.ID)
53 assert.Equal(t, expectedToken.Subject, token.Subject)
54 assert.Equal(t, expectedToken.Scopes, token.Scopes)
55}
56
57func TestPassStore_GetToken_CacheMiss_ReturnsNotFound(t *testing.T) {
58 store := newTestPassStore()
59
60 // Cache miss should try to call pass (which will fail in test env)
61 _, _, err := store.GetToken(context.Background(), "nonexistent")
62
63 // Should return ErrTokenNotFound since pass won't have this token
64 assert.ErrorIs(t, err, domain.ErrTokenNotFound)
65}
66
67func TestPassStore_IsRevoked_CacheHit_Revoked(t *testing.T) {
68 store := newTestPassStore()
69
70 // Pre-populate revocation cache
71 store.cacheMu.Lock()
72 store.revokedList = []string{"revoked-token-1", "revoked-token-2"}
73 store.revokedSet = map[string]struct{}{
74 "revoked-token-1": {},
75 "revoked-token-2": {},
76 }
77 store.cacheMu.Unlock()
78
79 // Check revoked token
80 revoked, err := store.IsRevoked(context.Background(), "revoked-token-1")
81 require.NoError(t, err)
82 assert.True(t, revoked)
83}
84
85func TestPassStore_IsRevoked_CacheHit_NotRevoked(t *testing.T) {
86 store := newTestPassStore()
87
88 // Pre-populate revocation cache with some revoked tokens
89 store.cacheMu.Lock()
90 store.revokedList = []string{"revoked-token-1"}
91 store.revokedSet = map[string]struct{}{
92 "revoked-token-1": {},
93 }
94 store.cacheMu.Unlock()
95
96 // Check non-revoked token (cache is populated, so no pass call)
97 revoked, err := store.IsRevoked(context.Background(), "valid-token")
98 require.NoError(t, err)
99 assert.False(t, revoked)
100}
101
102func TestPassStore_CacheUpdate_OnSave(t *testing.T) {
103 store := newTestPassStore()
104
105 token := &domain.Token{
106 ID: "new-token-id",
107 Subject: "new-subject",
108 Scopes: []string{"push"},
109 IssuedAt: time.Now(),
110 ExpiresAt: time.Now().Add(24 * time.Hour),
111 }
112 jwt := "new-jwt-token"
113
114 // Simulate what SaveToken does to the cache
115 store.cacheMu.Lock()
116 store.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
117 store.cacheMu.Unlock()
118
119 // Verify cache was updated - subsequent GetToken should hit cache
120 gotJWT, gotToken, err := store.GetToken(context.Background(), "new-subject")
121
122 require.NoError(t, err)
123 assert.Equal(t, jwt, gotJWT)
124 assert.Equal(t, token.ID, gotToken.ID)
125}
126
127func TestPassStore_CacheClear_OnDelete(t *testing.T) {
128 store := newTestPassStore()
129
130 // Pre-populate cache
131 store.cacheMu.Lock()
132 store.tokenCache["to-delete"] = &cachedToken{
133 jwt: "some-jwt",
134 token: &domain.Token{ID: "id", Subject: "to-delete"},
135 }
136 store.cacheMu.Unlock()
137
138 // Simulate what DeleteToken does to the cache
139 store.cacheMu.Lock()
140 delete(store.tokenCache, "to-delete")
141 store.cacheMu.Unlock()
142
143 // Verify cache entry was removed
144 store.cacheMu.RLock()
145 _, ok := store.tokenCache["to-delete"]
146 store.cacheMu.RUnlock()
147
148 assert.False(t, ok)
149}
150
151func TestPassStore_GetToken_ConcurrentAccess(t *testing.T) {
152 store := newTestPassStore()
153
154 // Pre-populate cache
155 expectedToken := &domain.Token{
156 ID: "concurrent-id",
157 Subject: "concurrent-subject",
158 Scopes: []string{"push", "pull"},
159 }
160 expectedJWT := "concurrent-jwt"
161
162 store.cacheMu.Lock()
163 store.tokenCache["concurrent-subject"] = &cachedToken{
164 jwt: expectedJWT,
165 token: expectedToken,
166 }
167 store.cacheMu.Unlock()
168
169 // Simulate concurrent access (like Docker buildx parallel requests)
170 var wg sync.WaitGroup
171 errors := make([]error, 0)
172 var errMu sync.Mutex
173
174 for i := 0; i < 100; i++ {
175 wg.Add(1)
176 go func() {
177 defer wg.Done()
178 jwt, token, err := store.GetToken(context.Background(), "concurrent-subject")
179 if err != nil {
180 errMu.Lock()
181 errors = append(errors, err)
182 errMu.Unlock()
183 return
184 }
185 if jwt != expectedJWT || token.ID != expectedToken.ID {
186 errMu.Lock()
187 errors = append(errors, assert.AnError)
188 errMu.Unlock()
189 }
190 }()
191 }
192
193 wg.Wait()
194 assert.Empty(t, errors, "concurrent access should not produce errors")
195}
196
197func TestPassStore_IsRevoked_ConcurrentAccess(t *testing.T) {
198 store := newTestPassStore()
199
200 // Pre-populate revocation cache
201 store.cacheMu.Lock()
202 store.revokedList = []string{"revoked-1"}
203 store.revokedSet = map[string]struct{}{"revoked-1": {}}
204 store.cacheMu.Unlock()
205
206 // Simulate concurrent access
207 var wg sync.WaitGroup
208 errors := make([]error, 0)
209 var errMu sync.Mutex
210
211 for i := 0; i < 100; i++ {
212 wg.Add(1)
213 go func() {
214 defer wg.Done()
215 revoked, err := store.IsRevoked(context.Background(), "revoked-1")
216 if err != nil {
217 errMu.Lock()
218 errors = append(errors, err)
219 errMu.Unlock()
220 return
221 }
222 if !revoked {
223 errMu.Lock()
224 errors = append(errors, assert.AnError)
225 errMu.Unlock()
226 }
227 }()
228 }
229
230 wg.Wait()
231 assert.Empty(t, errors, "concurrent access should not produce errors")
232}
233
234func TestPassStore_RevokeUpdatesCache(t *testing.T) {
235 store := newTestPassStore()
236
237 // Initialize revocation cache with an older revoked token
238 store.cacheMu.Lock()
239 store.revokedList = []string{"oldly-revoked"}
240 store.revokedSet = make(map[string]struct{})
241 store.cacheMu.Unlock()
242
243 // Simulate what Revoke does to the cache
244 tokenID := "newly-revoked"
245 store.cacheMu.Lock()
246 store.revokedList = append(store.revokedList, tokenID)
247 store.revokedSet = make(map[string]struct{}, len(store.revokedList))
248 for _, id := range store.revokedList {
249 store.revokedSet[id] = struct{}{}
250 }
251 store.cacheMu.Unlock()
252
253 // Verify IsRevoked now returns true from cache
254 revoked, err := store.IsRevoked(context.Background(), tokenID)
255 require.NoError(t, err)
256 assert.True(t, revoked)
257 revoked, err = store.IsRevoked(context.Background(), "oldly-revoked")
258 require.NoError(t, err)
259 assert.True(t, revoked)
260}