v2.9.0 8ebdfe0

pass_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close