redis-client-session-store 0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,646 @@
1
+ require 'json'
2
+
3
+ describe RedisSessionStore do
4
+ subject(:store) { described_class.new(nil, redis_options.merge(options)) }
5
+
6
+ let :random_string do
7
+ "#{rand}#{rand}#{rand}"
8
+ end
9
+ let :default_options do
10
+ store.instance_variable_get(:@default_options)
11
+ end
12
+
13
+ let :options do
14
+ {}
15
+ end
16
+
17
+ let :redis_options do
18
+ { redis: { url: ENV['REDIS_URL'] } }
19
+ end
20
+
21
+ it 'assigns a :namespace to @default_options' do
22
+ expect(default_options[:namespace]).to eq('rack:session')
23
+ end
24
+
25
+ describe 'when initializing with the redis sub-hash options' do
26
+ let :options do
27
+ {
28
+ key: random_string,
29
+ secret: random_string,
30
+ redis: {
31
+ host: 'hosty.local',
32
+ port: 16_379,
33
+ db: 2,
34
+ key_prefix: 'myapp:session:',
35
+ expire_after: 60 * 120
36
+ }
37
+ }
38
+ end
39
+
40
+ it 'creates a redis instance' do
41
+ expect(store.instance_variable_get(:@redis)).not_to be_nil
42
+ end
43
+
44
+ it 'assigns the :host option to @default_options' do
45
+ expect(default_options[:host]).to eq('hosty.local')
46
+ end
47
+
48
+ it 'assigns the :port option to @default_options' do
49
+ expect(default_options[:port]).to eq(16_379)
50
+ end
51
+
52
+ it 'assigns the :db option to @default_options' do
53
+ expect(default_options[:db]).to eq(2)
54
+ end
55
+
56
+ it 'assigns the :key_prefix option to @default_options' do
57
+ expect(default_options[:key_prefix]).to eq('myapp:session:')
58
+ end
59
+
60
+ it 'assigns the :expire_after option to @default_options' do
61
+ expect(default_options[:expire_after]).to eq(60 * 120)
62
+ end
63
+ end
64
+
65
+ describe 'when configured with both :ttl and :expire_after' do
66
+ let(:ttl_seconds) { 60 * 120 }
67
+ let :options do
68
+ {
69
+ key: random_string,
70
+ secret: random_string,
71
+ redis: {
72
+ host: 'hosty.local',
73
+ port: 16_379,
74
+ db: 2,
75
+ key_prefix: 'myapp:session:',
76
+ ttl: ttl_seconds,
77
+ expire_after: nil
78
+ }
79
+ }
80
+ end
81
+
82
+ it 'assigns the :ttl option to @default_options' do
83
+ expect(default_options[:ttl]).to eq(ttl_seconds)
84
+ expect(default_options[:expire_after]).to be_nil
85
+ end
86
+ end
87
+
88
+ describe 'when initializing with top-level redis options' do
89
+ let :options do
90
+ {
91
+ key: random_string,
92
+ secret: random_string,
93
+ host: 'hostersons.local',
94
+ port: 26_379,
95
+ db: 4,
96
+ key_prefix: 'appydoo:session:',
97
+ expire_after: 60 * 60
98
+ }
99
+ end
100
+
101
+ it 'creates a redis instance' do
102
+ expect(store.instance_variable_get(:@redis)).not_to be_nil
103
+ end
104
+
105
+ it 'assigns the :host option to @default_options' do
106
+ expect(default_options[:host]).to eq('hostersons.local')
107
+ end
108
+
109
+ it 'assigns the :port option to @default_options' do
110
+ expect(default_options[:port]).to eq(26_379)
111
+ end
112
+
113
+ it 'assigns the :db option to @default_options' do
114
+ expect(default_options[:db]).to eq(4)
115
+ end
116
+
117
+ it 'assigns the :key_prefix option to @default_options' do
118
+ expect(default_options[:key_prefix]).to eq('appydoo:session:')
119
+ end
120
+
121
+ it 'assigns the :expire_after option to @default_options' do
122
+ expect(default_options[:expire_after]).to eq(60 * 60)
123
+ end
124
+ end
125
+
126
+ describe 'when initializing with existing redis object' do
127
+ let :options do
128
+ {
129
+ key: random_string,
130
+ secret: random_string,
131
+ redis: {
132
+ client: redis_client,
133
+ key_prefix: 'myapp:session:',
134
+ expire_after: 60 * 30
135
+ }
136
+ }
137
+ end
138
+
139
+ let(:redis_client) { double('redis_client') }
140
+
141
+ it 'assigns given redis object to @redis' do
142
+ expect(store.instance_variable_get(:@redis)).to be(redis_client)
143
+ end
144
+
145
+ it 'assigns the :client option to @default_options' do
146
+ expect(default_options[:client]).to be(redis_client)
147
+ end
148
+
149
+ it 'assigns the :key_prefix option to @default_options' do
150
+ expect(default_options[:key_prefix]).to eq('myapp:session:')
151
+ end
152
+
153
+ it 'assigns the :expire_after option to @default_options' do
154
+ expect(default_options[:expire_after]).to eq(60 * 30)
155
+ end
156
+ end
157
+
158
+ describe 'rack 1.45 compatibility' do
159
+ # Rack 1.45 (which Rails 3.2.x depends on) uses the return value of
160
+ # set_session to set the cookie value. See:
161
+ # https://github.com/rack/rack/blob/1.4.5/lib/rack/session/abstract/id.rb
162
+
163
+ let(:env) { double('env') }
164
+ let(:session_id) { Rack::Session::SessionId.new('12 345') }
165
+ let(:session_data) { double('session_data') }
166
+ let(:options) { { expire_after: 123 } }
167
+
168
+ context 'when successfully persisting the session' do
169
+ it 'returns the session id' do
170
+ expect(store.send(:set_session, env, session_id, session_data, options))
171
+ .to eq(session_id)
172
+ end
173
+ end
174
+
175
+ context 'when unsuccessfully persisting the session' do
176
+ before do
177
+ allow(store).to receive(:redis).and_raise(RedisClient::CannotConnectError)
178
+ end
179
+
180
+ it 'returns false' do
181
+ expect(store.send(:set_session, env, session_id, session_data, options))
182
+ .to eq(false)
183
+ end
184
+ end
185
+
186
+ context 'when no expire_after option is given' do
187
+ let(:options) { {} }
188
+
189
+ it 'sets the session value without expiry' do
190
+ expect(store.send(:set_session, env, session_id, session_data, options))
191
+ .to eq(session_id)
192
+ end
193
+ end
194
+
195
+ context 'when redis is down' do
196
+ before do
197
+ allow(store).to receive(:redis).and_raise(RedisClient::CannotConnectError)
198
+ store.on_redis_down = ->(*_a) { @redis_down_handled = true }
199
+ end
200
+
201
+ it 'returns false' do
202
+ expect(store.send(:set_session, env, session_id, session_data, options))
203
+ .to eq(false)
204
+ end
205
+
206
+ it 'calls the on_redis_down handler' do
207
+ store.send(:set_session, env, session_id, session_data, options)
208
+ expect(@redis_down_handled).to eq(true)
209
+ end
210
+
211
+ context 'when :on_redis_down re-raises' do
212
+ before { store.on_redis_down = ->(e, *) { raise e } }
213
+
214
+ it 'explodes' do
215
+ expect do
216
+ store.send(:set_session, env, session_id, session_data, options)
217
+ end.to raise_error(RedisClient::CannotConnectError)
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ describe 'checking for session existence' do
224
+ let(:public_id) { 'foo' }
225
+ let(:session_id) { Rack::Session::SessionId.new(public_id) }
226
+
227
+ before do
228
+ allow(store).to receive(:current_session_id)
229
+ .with(:env).and_return(session_id)
230
+ end
231
+
232
+ context 'when session id is not provided' do
233
+ context 'when session id is nil' do
234
+ let(:session_id) { nil }
235
+
236
+ it 'returns false' do
237
+ expect(store.send(:session_exists?, :env)).to eq(false)
238
+ end
239
+ end
240
+
241
+ context 'when session id is empty string' do
242
+ let(:public_id) { '' }
243
+
244
+ it 'returns false' do
245
+ expect(store.send(:session_exists?, :env)).to eq(false)
246
+ end
247
+ end
248
+ end
249
+
250
+ context 'when session id is provided' do
251
+ let(:redis) do
252
+ double('redis').tap do |o|
253
+ allow(store).to receive(:redis).and_return(o)
254
+ end
255
+ end
256
+
257
+ context 'when session private id does not exist in redis' do
258
+ context 'when session public id does not exist in redis' do
259
+ it 'returns false' do
260
+ expect(redis).to receive(:call)
261
+ .with('EXISTS', session_id.private_id)
262
+ .and_return(0)
263
+ expect(redis).to receive(:call).with('EXISTS', 'foo').and_return(0)
264
+ expect(store.send(:session_exists?, :env)).to eq(false)
265
+ end
266
+ end
267
+
268
+ context 'when session public id exists in redis' do
269
+ it 'returns true' do
270
+ expect(redis).to receive(:call)
271
+ .with('EXISTS', session_id.private_id)
272
+ .and_return(0)
273
+ expect(redis).to receive(:call).with('EXISTS', 'foo').and_return(1)
274
+ expect(store.send(:session_exists?, :env)).to eq(true)
275
+ end
276
+ end
277
+ end
278
+
279
+ context 'when session private id exists in redis' do
280
+ it 'returns true' do
281
+ expect(redis).to receive(:call)
282
+ .with('EXISTS', session_id.private_id)
283
+ .and_return(1)
284
+ expect(store.send(:session_exists?, :env)).to eq(true)
285
+ end
286
+ end
287
+
288
+ context 'when session public id is formatted like a private id' do
289
+ let(:public_id) { Rack::Session::SessionId.new('foo').private_id }
290
+
291
+ it 'returns false' do
292
+ expect(redis).not_to receive(:call)
293
+ expect(store.send(:session_exists?, :env)).to eq(false)
294
+ end
295
+ end
296
+
297
+ context 'when redis is down' do
298
+ it 'returns true (fallback to old behavior)' do
299
+ allow(store).to receive(:redis).and_raise(RedisClient::CannotConnectError)
300
+ expect(store.send(:session_exists?, :env)).to eq(true)
301
+ end
302
+ end
303
+ end
304
+ end
305
+
306
+ describe 'fetching a session' do
307
+ let :options do
308
+ {
309
+ key_prefix: 'customprefix::'
310
+ }
311
+ end
312
+
313
+ let(:fake_key) { 'thisisarediskey' }
314
+ let(:session_id) { Rack::Session::SessionId.new(fake_key) }
315
+
316
+ describe 'generate_sid' do
317
+ it 'generates a secure ID' do
318
+ sid = store.send(:generate_sid)
319
+ expect(sid).to be_a(Rack::Session::SessionId)
320
+ end
321
+ end
322
+
323
+ context 'when redis is up' do
324
+ let(:redis) { double('redis') }
325
+ let(:private_exists) { 1 }
326
+
327
+ before do
328
+ allow(store).to receive(:redis).and_return(redis)
329
+ allow(redis).to receive(:call)
330
+ .with('EXISTS', "#{options[:key_prefix]}#{session_id.private_id}")
331
+ .and_return(private_exists)
332
+ end
333
+
334
+ context 'when session private id exists in redis' do
335
+ it 'retrieves the prefixed private id from redis' do
336
+ expect(redis).to receive(:call).with('GET',
337
+ "#{options[:key_prefix]}#{session_id.private_id}")
338
+
339
+ store.send(:get_session, double('env'), session_id)
340
+ end
341
+ end
342
+
343
+ context 'when session private id not found in redis' do
344
+ let(:private_exists) { 0 }
345
+
346
+ it 'retrieves the prefixed public id from redis' do
347
+ expect(redis).to receive(:call).with('GET', "#{options[:key_prefix]}#{fake_key}")
348
+
349
+ store.send(:get_session, double('env'), session_id)
350
+ end
351
+ end
352
+
353
+ context 'when session id is formatted like a private id' do
354
+ let(:fake_key) { Rack::Session::SessionId.new('anykey').private_id }
355
+ let(:new_sid) { Rack::Session::SessionId.new('newid') }
356
+
357
+ before do
358
+ allow(store).to receive(:generate_sid).and_return(new_sid)
359
+ end
360
+
361
+ it 'returns a default new session' do
362
+ expect(redis).not_to receive(:call)
363
+ expect(redis).not_to receive(:get)
364
+ expect(store.send(:get_session, double('env'), session_id))
365
+ .to eq([new_sid, {}])
366
+ end
367
+ end
368
+ end
369
+
370
+ context 'when redis is down' do
371
+ before do
372
+ allow(store).to receive(:redis).and_raise(RedisClient::CannotConnectError)
373
+ allow(store).to receive(:generate_sid).and_return('foop')
374
+ end
375
+
376
+ it 'returns an empty session hash' do
377
+ expect(store.send(:get_session, double('env'), session_id).last)
378
+ .to eq({})
379
+ end
380
+
381
+ it 'returns a newly generated sid' do
382
+ expect(store.send(:get_session, double('env'), session_id).first)
383
+ .to eq('foop')
384
+ end
385
+
386
+ context 'when :on_redis_down re-raises' do
387
+ before { store.on_redis_down = ->(e, *) { raise e } }
388
+
389
+ it 'explodes' do
390
+ expect do
391
+ store.send(:get_session, double('env'), session_id)
392
+ end.to raise_error(RedisClient::CannotConnectError)
393
+ end
394
+ end
395
+ end
396
+ end
397
+
398
+ describe 'destroying a session' do
399
+ context 'when the key is in the cookie hash' do
400
+ let(:env) { { 'rack.request.cookie_hash' => cookie_hash } }
401
+ let(:cookie_hash) { double('cookie hash') }
402
+ let(:fake_key) { 'thisisarediskey' }
403
+ let(:session_id) { Rack::Session::SessionId.new(fake_key) }
404
+
405
+ before do
406
+ allow(cookie_hash).to receive(:[]).and_return(fake_key)
407
+ end
408
+
409
+ it 'deletes the prefixed key from redis' do
410
+ redis = double('redis')
411
+ allow(store).to receive(:redis).and_return(redis)
412
+ expect(redis).to receive(:call).with('DEL', "#{options[:key_prefix]}#{fake_key}")
413
+ expect(redis).to receive(:call).with('DEL',
414
+ "#{options[:key_prefix]}#{session_id.private_id}")
415
+
416
+ store.send(:destroy, env)
417
+ end
418
+
419
+ context 'when redis is down' do
420
+ before do
421
+ allow(store).to receive(:redis).and_raise(RedisClient::CannotConnectError)
422
+ end
423
+
424
+ it 'returns false' do
425
+ expect(store.send(:destroy, env)).to eq(false)
426
+ end
427
+
428
+ context 'when :on_redis_down re-raises' do
429
+ before { store.on_redis_down = ->(e, *) { raise e } }
430
+
431
+ it 'explodes' do
432
+ expect do
433
+ store.send(:destroy, env)
434
+ end.to raise_error(RedisClient::CannotConnectError)
435
+ end
436
+ end
437
+ end
438
+ end
439
+
440
+ context 'when destroyed via #destroy_session' do
441
+ it 'deletes the prefixed key from redis' do
442
+ redis = redis_double
443
+ allow(store).to receive(:redis).and_return(redis)
444
+ sid = store.send(:generate_sid)
445
+ expect(redis).to receive(:call).with('DEL', "#{options[:key_prefix]}#{sid.public_id}")
446
+ expect(redis).to receive(:call).with('DEL', "#{options[:key_prefix]}#{sid.private_id}")
447
+
448
+ store.send(:destroy_session, {}, sid, nil)
449
+ end
450
+ end
451
+ end
452
+
453
+ describe 'session encoding' do
454
+ let(:env) { double('env') }
455
+ let(:session_id) { Rack::Session::SessionId.new('12 345') }
456
+ let(:session_data) { { 'some' => 'data' } }
457
+ let(:options) { {} }
458
+ let(:encoded_data) { Marshal.dump(session_data) }
459
+ let(:redis) { redis_double(set: nil, get: encoded_data) }
460
+ let(:expected_encoding) { encoded_data }
461
+
462
+ before do
463
+ allow(store).to receive(:redis).and_return(redis)
464
+ end
465
+
466
+ shared_examples_for 'serializer' do
467
+ it 'encodes correctly' do
468
+ expect(redis).to receive(:call).with('SET', session_id.private_id, expected_encoding)
469
+ store.send(:set_session, env, session_id, session_data, options)
470
+ end
471
+
472
+ it 'decodes correctly' do
473
+ allow(redis).to receive(:call).with('EXISTS', session_id.private_id).and_return(1)
474
+ expect(store.send(:get_session, env, session_id)).to eq([session_id, session_data])
475
+ end
476
+ end
477
+
478
+ context 'marshal' do
479
+ let(:options) { { serializer: :marshal } }
480
+
481
+ it_behaves_like 'serializer'
482
+ end
483
+
484
+ context 'json' do
485
+ let(:options) { { serializer: :json } }
486
+ let(:encoded_data) { '{"some":"data"}' }
487
+
488
+ it_behaves_like 'serializer'
489
+ end
490
+
491
+ context 'hybrid' do
492
+ let(:options) { { serializer: :hybrid } }
493
+ let(:expected_encoding) { '{"some":"data"}' }
494
+
495
+ context 'marshal encoded data' do
496
+ it_behaves_like 'serializer'
497
+ end
498
+
499
+ context 'json encoded data' do
500
+ let(:encoded_data) { '{"some":"data"}' }
501
+
502
+ it_behaves_like 'serializer'
503
+ end
504
+ end
505
+
506
+ context 'custom' do
507
+ let :custom_serializer do
508
+ Class.new do
509
+ def self.load(_value)
510
+ { 'some' => 'data' }
511
+ end
512
+
513
+ def self.dump(_value)
514
+ 'somedata'
515
+ end
516
+ end
517
+ end
518
+
519
+ let(:options) { { serializer: custom_serializer } }
520
+ let(:expected_encoding) { 'somedata' }
521
+
522
+ it_behaves_like 'serializer'
523
+ end
524
+ end
525
+
526
+ describe 'handling decode errors' do
527
+ context 'when a class is serialized that does not exist' do
528
+ before do
529
+ allow(store).to receive(:redis)
530
+ .and_return(redis_double(
531
+ get: "\x04\bo:\nNonExistentClass\x00",
532
+ del: true
533
+ ))
534
+ end
535
+
536
+ it 'returns an empty session' do
537
+ expect(store.send(:load_session_from_redis, 'whatever')).to be_nil
538
+ end
539
+
540
+ it 'destroys and drops the session' do
541
+ expect(store).to receive(:destroy_session_from_sid)
542
+ .with('wut', drop: true)
543
+ store.send(:load_session_from_redis, 'wut')
544
+ end
545
+
546
+ context 'when a custom on_session_load_error handler is provided' do
547
+ before do
548
+ store.on_session_load_error = lambda do |e, sid|
549
+ @e = e
550
+ @sid = sid
551
+ end
552
+ end
553
+
554
+ it 'passes the error and the sid to the handler' do
555
+ store.send(:load_session_from_redis, 'foo')
556
+ expect(@e).to be_kind_of(StandardError)
557
+ expect(@sid).to eq('foo')
558
+ end
559
+ end
560
+ end
561
+
562
+ context 'when the encoded data is invalid' do
563
+ before do
564
+ allow(store).to receive(:redis)
565
+ .and_return(redis_double(get: "\x00\x00\x00\x00", del: true))
566
+ end
567
+
568
+ it 'returns an empty session' do
569
+ expect(store.send(:load_session_from_redis, 'bar')).to be_nil
570
+ end
571
+
572
+ it 'destroys and drops the session' do
573
+ expect(store).to receive(:destroy_session_from_sid)
574
+ .with('wut', drop: true)
575
+ store.send(:load_session_from_redis, 'wut')
576
+ end
577
+
578
+ context 'when a custom on_session_load_error handler is provided' do
579
+ before do
580
+ store.on_session_load_error = lambda do |e, sid|
581
+ @e = e
582
+ @sid = sid
583
+ end
584
+ end
585
+
586
+ it 'passes the error and the sid to the handler' do
587
+ store.send(:load_session_from_redis, 'foo')
588
+ expect(@e).to be_kind_of(StandardError)
589
+ expect(@sid).to eq('foo')
590
+ end
591
+ end
592
+ end
593
+ end
594
+
595
+ describe 'validating custom handlers' do
596
+ %w(on_redis_down on_session_load_error).each do |h|
597
+ context 'when nil' do
598
+ it 'does not explode at init' do
599
+ expect { store }.not_to raise_error
600
+ end
601
+ end
602
+
603
+ context 'when callable' do
604
+ let(:options) { { "#{h}": ->(*) { true } } }
605
+
606
+ it 'does not explode at init' do
607
+ expect { store }.not_to raise_error
608
+ end
609
+ end
610
+
611
+ context 'when not callable' do
612
+ let(:options) { { "#{h}": 'herpderp' } }
613
+
614
+ it 'explodes at init' do
615
+ expect { store }.to raise_error(ArgumentError)
616
+ end
617
+ end
618
+ end
619
+ end
620
+
621
+ describe 'setting the session' do
622
+ it 'allows changing the session' do
623
+ env = { 'rack.session.options' => {} }
624
+ sid = Rack::Session::SessionId.new('1234')
625
+ allow(store).to receive(:redis).and_return(RedisClient.new(url: ENV['REDIS_URL']))
626
+ data1 = { 'foo' => 'bar' }
627
+ store.send(:set_session, env, sid, data1)
628
+ data2 = { 'baz' => 'wat' }
629
+ store.send(:set_session, env, sid, data2)
630
+ _, session = store.send(:get_session, env, sid)
631
+ expect(session).to eq(data2)
632
+ end
633
+
634
+ it 'allows changing the session when the session has an expiry' do
635
+ env = { 'rack.session.options' => { expire_after: 60 } }
636
+ sid = Rack::Session::SessionId.new('1234')
637
+ allow(store).to receive(:redis).and_return(RedisClient.new(url: ENV['REDIS_URL']))
638
+ data1 = { 'foo' => 'bar' }
639
+ store.send(:set_session, env, sid, data1)
640
+ data2 = { 'baz' => 'wat' }
641
+ store.send(:set_session, env, sid, data2)
642
+ _, session = store.send(:get_session, env, sid)
643
+ expect(session).to eq(data2)
644
+ end
645
+ end
646
+ end
@@ -0,0 +1,32 @@
1
+ require 'simplecov'
2
+ require_relative 'support'
3
+ require 'redis-client-session-store'
4
+
5
+ # from git submodule
6
+ require_relative 'support/redis-client/test/support/servers'
7
+ require_relative 'support/redis_double'
8
+
9
+ unless ENV['REDIS_URL']
10
+ class TestRedisManager < Servers::RedisManager
11
+ # workaround for "Failed opening Unix socket: unix socket path too long"
12
+ def command
13
+ super - ['--unixsocket', Servers::REDIS_SOCKET_FILE.to_s]
14
+ end
15
+ end
16
+
17
+ Dir.mkdir('tmp') unless Dir.exist?('tmp')
18
+ Servers.build_redis
19
+ REDIS = TestRedisManager.new(
20
+ 'redis',
21
+ port: 16_380,
22
+ real_port: 16_380
23
+ )
24
+ TEST_SERVERS = ServerList.new(REDIS)
25
+ TEST_SERVERS.prepare
26
+
27
+ ENV['REDIS_URL'] = "redis://#{REDIS.host}:#{REDIS.port}"
28
+
29
+ RSpec.configure do |config|
30
+ config.after(:suite) { TEST_SERVERS.shutdown }
31
+ end
32
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ def redis_double **args
4
+ r = double('redis')
5
+ args.each do |k, v|
6
+ allow(r).to receive(:call).with(k.to_s.upcase, anything).and_return(v)
7
+ end
8
+ r
9
+ end