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// TestParsePassLsOutput verifies that pass ls tree output is parsed correctly,
16// including subjects that contain "/" (multi-level paths).
17func TestParsePassLsOutput(t *testing.T) {
18 tests := []struct {
19 name string
20 input string
21 want []string
22 }{
23 {
24 name: "flat entries only",
25 input: `gordon/registry/tokens
26├── admin
27└── bob
28`,
29 want: []string{"admin", "bob"},
30 },
31 {
32 name: "nested entries with slash in subject",
33 input: `gordon/registry/tokens
34├── admin
35└── team
36 ├── alice
37 └── bob
38`,
39 want: []string{"admin", "team/alice", "team/bob"},
40 },
41 {
42 name: "deeply nested entries",
43 input: `gordon/registry/tokens
44└── org
45 └── team
46 └── alice
47`,
48 want: []string{"org/team/alice"},
49 },
50 {
51 name: "mixed flat and nested",
52 input: `gordon/registry/tokens
53├── admin
54├── team
55│ ├── alice
56│ └── bob
57└── standalone
58`,
59 want: []string{"admin", "team/alice", "team/bob", "standalone"},
60 },
61 {
62 name: "empty output (no entries)",
63 input: "gordon/registry/tokens\n",
64 want: []string{},
65 },
66 {
67 name: "ascii tree decoration variant",
68 input: `gordon/registry/tokens
69|-- admin
70` + "`-- team\n `-- alice\n",
71 want: []string{"admin", "team/alice"},
72 },
73 }
74
75 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {
77 got := parsePassLsOutput(tt.input)
78 assert.Equal(t, tt.want, got)
79 })
80 }
81}
82
83// These tests verify the in-memory caching behavior of PassStore.
84// They directly manipulate the cache to test cache hit/miss logic
85// without requiring the actual pass binary.
86
87func newTestPassStore() *PassStore {
88 return &PassStore{
89 timeout: 10 * time.Second,
90 tokenCache: make(map[string]*cachedToken),
91 revokedSet: make(map[string]struct{}),
92 }
93}
94
95func TestPassStore_GetToken_CacheHit(t *testing.T) {
96 store := newTestPassStore()
97
98 // Pre-populate cache
99 expectedToken := &domain.Token{
100 ID: "test-id",
101 Subject: "test-subject",
102 Scopes: []string{"push", "pull"},
103 IssuedAt: time.Now(),
104 ExpiresAt: time.Now().Add(24 * time.Hour),
105 }
106 expectedJWT := "cached-jwt-token"
107
108 store.cacheMu.Lock()
109 store.tokenCache["test-subject"] = &cachedToken{
110 jwt: expectedJWT,
111 token: expectedToken,
112 }
113 store.cacheMu.Unlock()
114
115 // Should return cached value without calling pass
116 jwt, token, err := store.GetToken(context.Background(), "test-subject")
117
118 require.NoError(t, err)
119 assert.Equal(t, expectedJWT, jwt)
120 assert.Equal(t, expectedToken.ID, token.ID)
121 assert.Equal(t, expectedToken.Subject, token.Subject)
122 assert.Equal(t, expectedToken.Scopes, token.Scopes)
123}
124
125func TestPassStore_GetToken_CacheMiss_ReturnsNotFound(t *testing.T) {
126 store := newTestPassStore()
127
128 // Cache miss should try to call pass (which will fail in test env)
129 _, _, err := store.GetToken(context.Background(), "nonexistent")
130
131 // Should return ErrTokenNotFound since pass won't have this token
132 assert.ErrorIs(t, err, domain.ErrTokenNotFound)
133}
134
135func TestPassStore_IsRevoked_CacheHit_Revoked(t *testing.T) {
136 store := newTestPassStore()
137
138 // Pre-populate revocation cache
139 store.cacheMu.Lock()
140 store.revokedList = []string{"revoked-token-1", "revoked-token-2"}
141 store.revokedSet = map[string]struct{}{
142 "revoked-token-1": {},
143 "revoked-token-2": {},
144 }
145 store.cacheMu.Unlock()
146
147 // Check revoked token
148 revoked, err := store.IsRevoked(context.Background(), "revoked-token-1")
149 require.NoError(t, err)
150 assert.True(t, revoked)
151}
152
153func TestPassStore_IsRevoked_CacheHit_NotRevoked(t *testing.T) {
154 store := newTestPassStore()
155
156 // Pre-populate revocation cache with some revoked tokens
157 store.cacheMu.Lock()
158 store.revokedList = []string{"revoked-token-1"}
159 store.revokedSet = map[string]struct{}{
160 "revoked-token-1": {},
161 }
162 store.cacheMu.Unlock()
163
164 // Check non-revoked token (cache is populated, so no pass call)
165 revoked, err := store.IsRevoked(context.Background(), "valid-token")
166 require.NoError(t, err)
167 assert.False(t, revoked)
168}
169
170func TestPassStore_CacheUpdate_OnSave(t *testing.T) {
171 store := newTestPassStore()
172
173 token := &domain.Token{
174 ID: "new-token-id",
175 Subject: "new-subject",
176 Scopes: []string{"push"},
177 IssuedAt: time.Now(),
178 ExpiresAt: time.Now().Add(24 * time.Hour),
179 }
180 jwt := "new-jwt-token"
181
182 // Simulate what SaveToken does to the cache
183 store.cacheMu.Lock()
184 store.tokenCache[token.Subject] = &cachedToken{jwt: jwt, token: token}
185 store.cacheMu.Unlock()
186
187 // Verify cache was updated - subsequent GetToken should hit cache
188 gotJWT, gotToken, err := store.GetToken(context.Background(), "new-subject")
189
190 require.NoError(t, err)
191 assert.Equal(t, jwt, gotJWT)
192 assert.Equal(t, token.ID, gotToken.ID)
193}
194
195func TestPassStore_CacheClear_OnDelete(t *testing.T) {
196 store := newTestPassStore()
197
198 // Pre-populate cache
199 store.cacheMu.Lock()
200 store.tokenCache["to-delete"] = &cachedToken{
201 jwt: "some-jwt",
202 token: &domain.Token{ID: "id", Subject: "to-delete"},
203 }
204 store.cacheMu.Unlock()
205
206 // Simulate what DeleteToken does to the cache
207 store.cacheMu.Lock()
208 delete(store.tokenCache, "to-delete")
209 store.cacheMu.Unlock()
210
211 // Verify cache entry was removed
212 store.cacheMu.RLock()
213 _, ok := store.tokenCache["to-delete"]
214 store.cacheMu.RUnlock()
215
216 assert.False(t, ok)
217}
218
219func TestPassStore_GetToken_ConcurrentAccess(t *testing.T) {
220 store := newTestPassStore()
221
222 // Pre-populate cache
223 expectedToken := &domain.Token{
224 ID: "concurrent-id",
225 Subject: "concurrent-subject",
226 Scopes: []string{"push", "pull"},
227 }
228 expectedJWT := "concurrent-jwt"
229
230 store.cacheMu.Lock()
231 store.tokenCache["concurrent-subject"] = &cachedToken{
232 jwt: expectedJWT,
233 token: expectedToken,
234 }
235 store.cacheMu.Unlock()
236
237 // Simulate concurrent access (like Docker buildx parallel requests)
238 var wg sync.WaitGroup
239 errors := make([]error, 0)
240 var errMu sync.Mutex
241
242 for i := 0; i < 100; i++ {
243 wg.Add(1)
244 go func() {
245 defer wg.Done()
246 jwt, token, err := store.GetToken(context.Background(), "concurrent-subject")
247 if err != nil {
248 errMu.Lock()
249 errors = append(errors, err)
250 errMu.Unlock()
251 return
252 }
253 if jwt != expectedJWT || token.ID != expectedToken.ID {
254 errMu.Lock()
255 errors = append(errors, assert.AnError)
256 errMu.Unlock()
257 }
258 }()
259 }
260
261 wg.Wait()
262 assert.Empty(t, errors, "concurrent access should not produce errors")
263}
264
265func TestPassStore_IsRevoked_ConcurrentAccess(t *testing.T) {
266 store := newTestPassStore()
267
268 // Pre-populate revocation cache
269 store.cacheMu.Lock()
270 store.revokedList = []string{"revoked-1"}
271 store.revokedSet = map[string]struct{}{"revoked-1": {}}
272 store.cacheMu.Unlock()
273
274 // Simulate concurrent access
275 var wg sync.WaitGroup
276 errors := make([]error, 0)
277 var errMu sync.Mutex
278
279 for i := 0; i < 100; i++ {
280 wg.Add(1)
281 go func() {
282 defer wg.Done()
283 revoked, err := store.IsRevoked(context.Background(), "revoked-1")
284 if err != nil {
285 errMu.Lock()
286 errors = append(errors, err)
287 errMu.Unlock()
288 return
289 }
290 if !revoked {
291 errMu.Lock()
292 errors = append(errors, assert.AnError)
293 errMu.Unlock()
294 }
295 }()
296 }
297
298 wg.Wait()
299 assert.Empty(t, errors, "concurrent access should not produce errors")
300}
301
302func TestPassStore_RevokeUpdatesCache(t *testing.T) {
303 store := newTestPassStore()
304
305 // Initialize revocation cache with an older revoked token
306 store.cacheMu.Lock()
307 store.revokedList = []string{"oldly-revoked"}
308 store.revokedSet = make(map[string]struct{})
309 store.cacheMu.Unlock()
310
311 // Simulate what Revoke does to the cache
312 tokenID := "newly-revoked"
313 store.cacheMu.Lock()
314 store.revokedList = append(store.revokedList, tokenID)
315 store.revokedSet = make(map[string]struct{}, len(store.revokedList))
316 for _, id := range store.revokedList {
317 store.revokedSet[id] = struct{}{}
318 }
319 store.cacheMu.Unlock()
320
321 // Verify IsRevoked now returns true from cache
322 revoked, err := store.IsRevoked(context.Background(), tokenID)
323 require.NoError(t, err)
324 assert.True(t, revoked)
325 revoked, err = store.IsRevoked(context.Background(), "oldly-revoked")
326 require.NoError(t, err)
327 assert.True(t, revoked)
328}
329
330func TestPassStore_SaveToken_RejectsInvalidSubject(t *testing.T) {
331 store := newTestPassStore()
332
333 tests := []struct {
334 name string
335 subject string
336 }{
337 {"path traversal with ../", "../../../etc/passwd"},
338 {"path traversal with ..\\", "..\\..\\windows\\system32"},
339 {"shell metacharacter semicolon", "user;rm -rf /"},
340 {"shell metacharacter pipe", "user|cat /etc/passwd"},
341 {"shell metacharacter ampersand", "user&&malicious"},
342 {"shell metacharacter backtick", "user`evil`"},
343 {"shell metacharacter dollar sign", "user$(evil)"},
344 {"newline character", "user\nmalicious"},
345 {"null byte", "user\x00malicious"},
346 }
347
348 for _, tt := range tests {
349 t.Run(tt.name, func(t *testing.T) {
350 token := &domain.Token{
351 ID: "test-id",
352 Subject: tt.subject,
353 Scopes: []string{"push"},
354 IssuedAt: time.Now(),
355 ExpiresAt: time.Now().Add(24 * time.Hour),
356 }
357
358 err := store.SaveToken(context.Background(), token, "jwt-token")
359 assert.Error(t, err, "should reject invalid subject: %s", tt.subject)
360 assert.Contains(t, err.Error(), "invalid subject", "error message should mention invalid subject")
361 })
362 }
363}
364
365func TestPassStore_SaveToken_AcceptsValidSubject(t *testing.T) {
366 tests := []struct {
367 name string
368 subject string
369 }{
370 {"simple username", "myuser"},
371 {"username with dots", "user.name"},
372 {"username with hyphens", "user-name"},
373 {"username with underscores", "user_name"},
374 {"email-like", "user@example.com"},
375 {"path-like but safe", "registry/user"},
376 }
377
378 for _, tt := range tests {
379 t.Run(tt.name, func(t *testing.T) {
380 // Verify the validation logic accepts valid subjects
381 err := validateSubject(tt.subject)
382 assert.NoError(t, err, "should accept valid subject: %s", tt.subject)
383 })
384 }
385}
386
387func TestPassStore_GetToken_RejectsInvalidSubject(t *testing.T) {
388 store := newTestPassStore()
389
390 invalidSubjects := []string{
391 "../../../etc/passwd",
392 "user;rm -rf /",
393 "user|cat",
394 "user\nnewline",
395 }
396
397 for _, subject := range invalidSubjects {
398 t.Run(subject, func(t *testing.T) {
399 _, _, err := store.GetToken(context.Background(), subject)
400 assert.Error(t, err, "should reject invalid subject")
401 assert.Contains(t, err.Error(), "invalid subject")
402 })
403 }
404}
405
406func TestPassStore_DeleteToken_RejectsInvalidSubject(t *testing.T) {
407 store := newTestPassStore()
408
409 invalidSubjects := []string{
410 "../../../etc/passwd",
411 "user;rm -rf /",
412 "user|cat",
413 }
414
415 for _, subject := range invalidSubjects {
416 t.Run(subject, func(t *testing.T) {
417 err := store.DeleteToken(context.Background(), subject)
418 assert.Error(t, err, "should reject invalid subject")
419 assert.Contains(t, err.Error(), "invalid subject")
420 })
421 }
422}