redis-client-session-store 0.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -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