v2.31.0-dev4 f8fb41e

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// 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}

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