package auth import ( "context" "testing" "time" "github.com/alicebob/miniredis/v2" goredis "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newFailingSessionRedisClient(t *testing.T) *goredis.Client { t.Helper() client := goredis.NewClient(&goredis.Options{ Addr: "127.0.0.1:1", MaxRetries: 0, DialTimeout: 10 * time.Millisecond, ReadTimeout: 10 * time.Millisecond, WriteTimeout: 10 * time.Millisecond, }) t.Cleanup(func() { _ = client.Close() }) return client } func TestSessionStoreRotateRefreshSuccess(t *testing.T) { mr := miniredis.RunT(t) client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) store := NewSessionStore(client) ctx := context.Background() require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute)) require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti", "new-hash", 2*time.Minute)) _, err := store.GetRefresh(ctx, "old-jti") assert.Error(t, err) value, err := store.GetRefresh(ctx, "new-jti") require.NoError(t, err) assert.Equal(t, "new-hash", value) } func TestSessionStoreRotateRefreshAllowsConcurrentReuseWithinGrace(t *testing.T) { mr := miniredis.RunT(t) client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) store := NewSessionStore(client) ctx := context.Background() require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute)) // Tab A rotates first, tab B retries with the same old token within the // grace window: both must obtain valid new sessions. require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-a", "new-hash-a", 2*time.Minute)) require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-b", "new-hash-b", 2*time.Minute)) valueA, err := store.GetRefresh(ctx, "new-jti-a") require.NoError(t, err) assert.Equal(t, "new-hash-a", valueA) valueB, err := store.GetRefresh(ctx, "new-jti-b") require.NoError(t, err) assert.Equal(t, "new-hash-b", valueB) // A different (wrong) old hash must still be rejected during grace. err = store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti-c", "new-hash-c", 2*time.Minute) require.ErrorIs(t, err, ErrRefreshTokenMismatch) // The rotated old token stays hidden from session lookups during grace. _, err = store.GetRefresh(ctx, "old-jti") require.ErrorIs(t, err, ErrRefreshSessionNotFound) } func TestMemorySessionStoreRotateAllowsConcurrentReuseWithinGrace(t *testing.T) { store := NewSessionStore(nil) ctx := context.Background() require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute)) require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-a", "new-hash-a", 2*time.Minute)) require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti-b", "new-hash-b", 2*time.Minute)) _, err := store.GetRefresh(ctx, "old-jti") assert.Error(t, err) valueA, err := store.GetRefresh(ctx, "new-jti-a") require.NoError(t, err) assert.Equal(t, "new-hash-a", valueA) valueB, err := store.GetRefresh(ctx, "new-jti-b") require.NoError(t, err) assert.Equal(t, "new-hash-b", valueB) err = store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti-c", "new-hash-c", 2*time.Minute) require.ErrorIs(t, err, ErrRefreshTokenMismatch) } func TestSessionStoreRotateRefreshRejectsMismatch(t *testing.T) { mr := miniredis.RunT(t) client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) store := NewSessionStore(client) ctx := context.Background() require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute)) err := store.RotateRefresh(ctx, "old-jti", "wrong-hash", "new-jti", "new-hash", 2*time.Minute) require.ErrorIs(t, err, ErrRefreshTokenMismatch) value, getErr := store.GetRefresh(ctx, "old-jti") require.NoError(t, getErr) assert.Equal(t, "old-hash", value) _, getErr = store.GetRefresh(ctx, "new-jti") assert.Error(t, getErr) } func TestSessionStoreUsesMemoryFallbackWhenRedisUnavailable(t *testing.T) { store := NewSessionStore(newFailingSessionRedisClient(t)) ctx := context.Background() require.NoError(t, store.SaveRefresh(ctx, "old-jti", "old-hash", time.Minute)) value, err := store.GetRefresh(ctx, "old-jti") require.NoError(t, err) assert.Equal(t, "old-hash", value) require.NoError(t, store.RotateRefresh(ctx, "old-jti", "old-hash", "new-jti", "new-hash", time.Minute)) _, err = store.GetRefresh(ctx, "old-jti") assert.Error(t, err) value, err = store.GetRefresh(ctx, "new-jti") require.NoError(t, err) assert.Equal(t, "new-hash", value) require.NoError(t, store.Blacklist(ctx, "access-jti", time.Minute)) revoked, err := store.IsBlacklisted(ctx, "access-jti") require.NoError(t, err) assert.True(t, revoked) } func TestSessionStoreBumpSessionVersion(t *testing.T) { mr := miniredis.RunT(t) client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) store := NewSessionStore(client) ctx := context.Background() version, err := store.SessionVersion(ctx, 42) require.NoError(t, err) assert.Equal(t, int64(0), version) version, err = store.BumpSessionVersion(ctx, 42) require.NoError(t, err) assert.Equal(t, int64(1), version) version, err = store.SessionVersion(ctx, 42) require.NoError(t, err) assert.Equal(t, int64(1), version) } func TestSessionStoreSessionVersionKeepsFallbackBumpWhenRedisLags(t *testing.T) { mr := miniredis.RunT(t) client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() }) store := NewSessionStore(client) ctx := context.Background() require.NoError(t, mr.Set(sessionVersionKey(42), "1")) store.fallback.set(sessionVersionKey(42), "3", sessionVersionFallbackTTL) version, err := store.SessionVersion(ctx, 42) require.NoError(t, err) assert.Equal(t, int64(3), version) stored, err := mr.Get(sessionVersionKey(42)) require.NoError(t, err) assert.Equal(t, "3", stored) }