@@ -5,12 +5,14 @@ import (
55 "errors"
66 "net/http"
77 "net/http/httptest"
8+ "strings"
89 "sync"
910 "testing"
1011 "time"
1112
1213 "github.com/github/github-mcp-server/internal/githubv4mock"
1314 gogithub "github.com/google/go-github/v89/github"
15+ "github.com/muesli/cache2go"
1416 "github.com/shurcooL/githubv4"
1517 "github.com/stretchr/testify/require"
1618)
@@ -231,54 +233,100 @@ func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) {
231233 require .True (t , safe )
232234}
233235
234- func TestCacheNameForIdentity (t * testing.T ) {
235- t .Run ("deterministic for the same identity" , func (t * testing.T ) {
236- require .Equal (t , CacheNameForIdentity ("token-a" ), CacheNameForIdentity ("token-a" ))
237- })
236+ // TestRepoAccessCacheIdentityScopedKeys covers the key derivation that keeps
237+ // identities isolated inside a single shared cache table.
238+ func TestRepoAccessCacheIdentityScopedKeys (t * testing.T ) {
239+ restClient := newMockRESTServer (t , "write" )
240+ gqlClient , _ := newMockGQLClient (testUser , false )
241+
242+ newCache := func (opts ... RepoAccessOption ) * RepoAccessCache {
243+ return NewRepoAccessCache (gqlClient , restClient , opts ... )
244+ }
238245
239- t .Run ("distinct for different identities" , func (t * testing.T ) {
240- require .NotEqual (t , CacheNameForIdentity ("token-a" ), CacheNameForIdentity ("token-b" ))
241- })
246+ unscoped := newCache ().cacheKey (testOwner , testRepo )
247+ alice := newCache (WithIdentity ("token-alice" )).cacheKey (testOwner , testRepo )
248+ aliceAgain := newCache (WithIdentity ("token-alice" )).cacheKey (testOwner , testRepo )
249+ bob := newCache (WithIdentity ("token-bob" )).cacheKey (testOwner , testRepo )
242250
243- t .Run ("empty identity yields empty name" , func (t * testing.T ) {
244- require .Empty (t , CacheNameForIdentity ("" ))
245- })
251+ require .Equal (t , alice , aliceAgain , "the same identity must map to the same key so it keeps a warm cache" )
252+ require .NotEqual (t , alice , bob , "different identities must map to different keys" )
253+ require .NotEqual (t , alice , unscoped , "a scoped identity must not collide with unscoped entries" )
254+ require .NotContains (t , alice , "token-alice" , "the raw identity must never appear in a cache key" )
246255
247- t . Run ( "never contains the raw identity" , func ( t * testing. T ) {
248- name := CacheNameForIdentity ( "super-secret-token " )
249- require .NotContains (t , name , "super-secret- token" )
250- } )
256+ require . Equal ( t , unscoped , newCache ( WithIdentity ( "" )). cacheKey ( testOwner , testRepo ),
257+ "an empty identity must leave entries unscoped " )
258+ require .Equal (t , alice , newCache ( WithIdentity ( " token-alice" )). cacheKey ( strings . ToUpper ( testOwner ), strings . ToUpper ( testRepo )),
259+ "identity scoping must preserve owner/repo case-insensitivity" )
251260}
252261
253- // TestRepoAccessCacheIdentityScopedNamesPreventCrossIdentityLeakage is a
254- // regression test for issue #3107. It mirrors how the HTTP server must
255- // construct a RepoAccessCache per request: reusing the same
256- // lockdown.RepoAccessOption slice across requests but scoping the cache table
257- // name to CacheNameForIdentity(token). Two different identities querying the
258- // same owner/repo/author must each hit their own upstream clients rather than
259- // one being served from the other's cached decision .
260- func TestRepoAccessCacheIdentityScopedNamesPreventCrossIdentityLeakage (t * testing.T ) {
262+ // TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable is a regression
263+ // test for issue #3107. Isolating identities by allocating a cache2go table
264+ // per token grows a process-wide registry that is never reclaimed, so
265+ // isolation must instead come from the entry key inside a single table. This
266+ // asserts both halves: different identities cannot see each other's trust
267+ // decisions, and their entries share one table so ordinary TTL cleanup can
268+ // reclaim them .
269+ func TestRepoAccessCacheIdentityScopingIsolatesWithinOneTable (t * testing.T ) {
261270 ctx := t .Context ()
262271
263272 restClient := newMockRESTServer (t , "write" )
273+ table := cache2go .Cache (t .Name ())
274+ t .Cleanup (table .Flush )
275+
276+ newCache := func (gqlClient * githubv4.Client , identity string ) * RepoAccessCache {
277+ return NewRepoAccessCache (gqlClient , restClient , WithCacheName (t .Name ()), WithIdentity (identity ))
278+ }
264279
265280 aliceGQL , aliceTransport := newMockGQLClient ("alice" , true )
266- aliceCache := NewRepoAccessCache (aliceGQL , restClient , WithCacheName (CacheNameForIdentity ("token-alice" )))
267- _ , err := aliceCache .getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
281+ _ , err := newCache (aliceGQL , "token-alice" ).getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
268282 require .NoError (t , err )
269283 require .EqualValues (t , 1 , aliceTransport .CallCount ())
270284
271285 bobGQL , bobTransport := newMockGQLClient ("bob" , true )
272- bobCache := NewRepoAccessCache (bobGQL , restClient , WithCacheName (CacheNameForIdentity ("token-bob" )))
273- _ , err = bobCache .getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
286+ _ , err = newCache (bobGQL , "token-bob" ).getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
274287 require .NoError (t , err )
275- require .EqualValues (t , 1 , bobTransport .CallCount (), "a different identity must fetch its own trust decision, not reuse another identity's cached entry" )
288+ require .EqualValues (t , 1 , bobTransport .CallCount (),
289+ "a different identity must fetch its own trust decision, not reuse another identity's cached entry" )
290+
291+ require .EqualValues (t , 2 , table .Count (),
292+ "per-identity entries must be stored in one shared table rather than a table per identity" )
276293
277- // The same identity repeating a request must still hit the warm cache.
278- aliceCacheAgain := NewRepoAccessCache ( aliceGQL , restClient , WithCacheName ( CacheNameForIdentity ( "token-alice" )))
279- _ , err = aliceCacheAgain .getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
294+ // Repeating the same identity must hit the warm cache and must not
295+ // allocate additional storage.
296+ _ , err = newCache ( aliceGQL , "token-alice" ) .getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
280297 require .NoError (t , err )
281298 require .EqualValues (t , 1 , aliceTransport .CallCount (), "repeated requests from the same identity should reuse the warm cache" )
299+ require .EqualValues (t , 2 , table .Count (), "a repeated request from a known identity must not add another entry" )
300+ }
301+
302+ // TestRepoAccessCacheIdentityScopedEntriesAreReclaimed proves the storage held
303+ // for distinct identities is bounded: because identity scoping lives in the
304+ // entry key, per-identity state is removed by the cache table's ordinary TTL
305+ // cleanup. A table-per-identity design could not shrink this way, since
306+ // cache2go retains every named table for the life of the process.
307+ func TestRepoAccessCacheIdentityScopedEntriesAreReclaimed (t * testing.T ) {
308+ ctx := t .Context ()
309+
310+ restClient := newMockRESTServer (t , "write" )
311+ table := cache2go .Cache (t .Name ())
312+ t .Cleanup (table .Flush )
313+
314+ identities := []string {"token-a" , "token-b" , "token-c" }
315+ for _ , identity := range identities {
316+ gqlClient , _ := newMockGQLClient (testUser , false )
317+ cache := NewRepoAccessCache (gqlClient , restClient ,
318+ WithCacheName (t .Name ()),
319+ WithIdentity (identity ),
320+ WithTTL (500 * time .Millisecond ),
321+ )
322+ _ , err := cache .getRepoAccessInfo (ctx , testUser , testOwner , testRepo )
323+ require .NoError (t , err )
324+ }
325+
326+ require .EqualValues (t , len (identities ), table .Count (), "each identity should hold exactly one entry in the shared table" )
327+
328+ require .Eventually (t , func () bool { return table .Count () == 0 }, 30 * time .Second , 10 * time .Millisecond ,
329+ "per-identity entries must be reclaimed by ordinary TTL cleanup so cache storage stays bounded" )
282330}
283331
284332type flakyTransport struct {
0 commit comments