v2.15.1 c808592

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}
261
262func TestPassStore_SaveToken_RejectsInvalidSubject(t *testing.T) {
263	store := newTestPassStore()
264
265	tests := []struct {
266		name    string
267		subject string
268	}{
269		{"path traversal with ../", "../../../etc/passwd"},
270		{"path traversal with ..\\", "..\\..\\windows\\system32"},
271		{"shell metacharacter semicolon", "user;rm -rf /"},
272		{"shell metacharacter pipe", "user|cat /etc/passwd"},
273		{"shell metacharacter ampersand", "user&&malicious"},
274		{"shell metacharacter backtick", "user`evil`"},
275		{"shell metacharacter dollar sign", "user$(evil)"},
276		{"newline character", "user\nmalicious"},
277		{"null byte", "user\x00malicious"},
278	}
279
280	for _, tt := range tests {
281		t.Run(tt.name, func(t *testing.T) {
282			token := &domain.Token{
283				ID:        "test-id",
284				Subject:   tt.subject,
285				Scopes:    []string{"push"},
286				IssuedAt:  time.Now(),
287				ExpiresAt: time.Now().Add(24 * time.Hour),
288			}
289
290			err := store.SaveToken(context.Background(), token, "jwt-token")
291			assert.Error(t, err, "should reject invalid subject: %s", tt.subject)
292			assert.Contains(t, err.Error(), "invalid subject", "error message should mention invalid subject")
293		})
294	}
295}
296
297func TestPassStore_SaveToken_AcceptsValidSubject(t *testing.T) {
298	tests := []struct {
299		name    string
300		subject string
301	}{
302		{"simple username", "myuser"},
303		{"username with dots", "user.name"},
304		{"username with hyphens", "user-name"},
305		{"username with underscores", "user_name"},
306		{"email-like", "user@example.com"},
307		{"path-like but safe", "registry/user"},
308	}
309
310	for _, tt := range tests {
311		t.Run(tt.name, func(t *testing.T) {
312			// Verify the validation logic accepts valid subjects
313			err := validateSubject(tt.subject)
314			assert.NoError(t, err, "should accept valid subject: %s", tt.subject)
315		})
316	}
317}
318
319func TestPassStore_GetToken_RejectsInvalidSubject(t *testing.T) {
320	store := newTestPassStore()
321
322	invalidSubjects := []string{
323		"../../../etc/passwd",
324		"user;rm -rf /",
325		"user|cat",
326		"user\nnewline",
327	}
328
329	for _, subject := range invalidSubjects {
330		t.Run(subject, func(t *testing.T) {
331			_, _, err := store.GetToken(context.Background(), subject)
332			assert.Error(t, err, "should reject invalid subject")
333			assert.Contains(t, err.Error(), "invalid subject")
334		})
335	}
336}
337
338func TestPassStore_DeleteToken_RejectsInvalidSubject(t *testing.T) {
339	store := newTestPassStore()
340
341	invalidSubjects := []string{
342		"../../../etc/passwd",
343		"user;rm -rf /",
344		"user|cat",
345	}
346
347	for _, subject := range invalidSubjects {
348		t.Run(subject, func(t *testing.T) {
349			err := store.DeleteToken(context.Background(), subject)
350			assert.Error(t, err, "should reject invalid subject")
351			assert.Contains(t, err.Error(), "invalid subject")
352		})
353	}
354}

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