package session import ( "context" "encoding/json" "errors" "testing" "time" "galaxy/gateway/internal/config" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewRedisCache(t *testing.T) { t.Parallel() server := miniredis.RunT(t) tests := []struct { name string cfg config.SessionCacheRedisConfig wantErr string }{ { name: "valid config", cfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), DB: 2, KeyPrefix: "gateway:session:", LookupTimeout: 250 * time.Millisecond, }, }, { name: "empty addr", cfg: config.SessionCacheRedisConfig{ LookupTimeout: 250 * time.Millisecond, }, wantErr: "redis addr must not be empty", }, { name: "negative db", cfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), DB: -1, LookupTimeout: 250 * time.Millisecond, }, wantErr: "redis db must not be negative", }, { name: "non-positive lookup timeout", cfg: config.SessionCacheRedisConfig{ Addr: server.Addr(), }, wantErr: "lookup timeout must be positive", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() cache, err := NewRedisCache(tt.cfg) if tt.wantErr != "" { require.Error(t, err) require.ErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, cache.Close()) }) }) } } func TestRedisCachePing(t *testing.T) { t.Parallel() server := miniredis.RunT(t) cache := newTestRedisCache(t, server, config.SessionCacheRedisConfig{}) require.NoError(t, cache.Ping(context.Background())) } func TestRedisCacheLookup(t *testing.T) { t.Parallel() revokedAtMS := int64(123456789) tests := []struct { name string cfg config.SessionCacheRedisConfig requestID string seed func(*testing.T, *miniredis.Miniredis, config.SessionCacheRedisConfig) want Record wantErrIs error wantErrText string assertErrText string }{ { name: "active cache hit", requestID: "device-session-123", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-123", redisRecord{ DeviceSessionID: "device-session-123", UserID: "user-123", ClientPublicKey: "public-key-123", Status: StatusActive, }) }, want: Record{ DeviceSessionID: "device-session-123", UserID: "user-123", ClientPublicKey: "public-key-123", Status: StatusActive, }, }, { name: "missing session", requestID: "device-session-404", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, wantErrIs: ErrNotFound, assertErrText: "session cache record not found", }, { name: "revoked session", requestID: "device-session-revoked", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-revoked", redisRecord{ DeviceSessionID: "device-session-revoked", UserID: "user-777", ClientPublicKey: "public-key-777", Status: StatusRevoked, RevokedAtMS: &revokedAtMS, }) }, want: Record{ DeviceSessionID: "device-session-revoked", UserID: "user-777", ClientPublicKey: "public-key-777", Status: StatusRevoked, RevokedAtMS: &revokedAtMS, }, }, { name: "malformed json", requestID: "device-session-bad-json", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() server.Set(cfg.KeyPrefix+"device-session-bad-json", "{") }, wantErrText: "decode redis session record", }, { name: "unknown status", requestID: "device-session-unknown-status", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-unknown-status", redisRecord{ DeviceSessionID: "device-session-unknown-status", UserID: "user-1", ClientPublicKey: "public-key-1", Status: Status("paused"), }) }, wantErrText: `status "paused" is unsupported`, }, { name: "missing required field", requestID: "device-session-missing-user", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-missing-user", redisRecord{ DeviceSessionID: "device-session-missing-user", ClientPublicKey: "public-key-1", Status: StatusActive, }) }, wantErrText: "user_id must not be empty", }, { name: "device session id mismatch", requestID: "device-session-requested", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "gateway:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-requested", redisRecord{ DeviceSessionID: "device-session-other", UserID: "user-1", ClientPublicKey: "public-key-1", Status: StatusActive, }) }, wantErrText: `does not match requested "device-session-requested"`, }, { name: "key prefix is honored", requestID: "device-session-prefixed", cfg: config.SessionCacheRedisConfig{ KeyPrefix: "custom:session:", }, seed: func(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) { t.Helper() setRedisSessionRecord(t, server, cfg.KeyPrefix+"device-session-prefixed", redisRecord{ DeviceSessionID: "device-session-prefixed", UserID: "user-prefixed", ClientPublicKey: "public-key-prefixed", Status: StatusActive, }) setRedisSessionRecord(t, server, "gateway:session:device-session-prefixed", redisRecord{ DeviceSessionID: "device-session-prefixed", UserID: "wrong-user", ClientPublicKey: "wrong-key", Status: StatusRevoked, }) }, want: Record{ DeviceSessionID: "device-session-prefixed", UserID: "user-prefixed", ClientPublicKey: "public-key-prefixed", Status: StatusActive, }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() server := miniredis.RunT(t) cfg := tt.cfg cfg.Addr = server.Addr() cfg.DB = 0 cfg.LookupTimeout = 250 * time.Millisecond if tt.seed != nil { tt.seed(t, server, cfg) } cache := newTestRedisCache(t, server, cfg) record, err := cache.Lookup(context.Background(), tt.requestID) if tt.wantErrIs != nil || tt.wantErrText != "" { require.Error(t, err) if tt.wantErrIs != nil { assert.ErrorIs(t, err, tt.wantErrIs) } if tt.wantErrText != "" { assert.ErrorContains(t, err, tt.wantErrText) } if tt.assertErrText != "" { assert.ErrorContains(t, err, tt.assertErrText) } return } require.NoError(t, err) assert.Equal(t, tt.want, record) }) } } func newTestRedisCache(t *testing.T, server *miniredis.Miniredis, cfg config.SessionCacheRedisConfig) *RedisCache { t.Helper() if cfg.Addr == "" { cfg.Addr = server.Addr() } if cfg.LookupTimeout == 0 { cfg.LookupTimeout = 250 * time.Millisecond } cache, err := NewRedisCache(cfg) require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, cache.Close()) }) return cache } func setRedisSessionRecord(t *testing.T, server *miniredis.Miniredis, key string, record redisRecord) { t.Helper() payload, err := json.Marshal(record) require.NoError(t, err) server.Set(key, string(payload)) } func TestRedisCacheLookupNilContext(t *testing.T) { t.Parallel() server := miniredis.RunT(t) cache := newTestRedisCache(t, server, config.SessionCacheRedisConfig{}) _, err := cache.Lookup(context.TODO(), "device-session-123") require.Error(t, err) assert.False(t, errors.Is(err, ErrNotFound)) assert.ErrorContains(t, err, "nil context") }