brevio-session-store 0.1.0

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,564 @@
1
+ require 'json'
2
+
3
+ describe RedisSessionStore do
4
+ let :random_string do
5
+ "#{rand}#{rand}#{rand}"
6
+ end
7
+
8
+ let :options do
9
+ {}
10
+ end
11
+
12
+ subject(:store) { RedisSessionStore.new(nil, options) }
13
+
14
+ let :default_options do
15
+ store.instance_variable_get(:@default_options)
16
+ end
17
+
18
+ it 'assigns a :namespace to @default_options' do
19
+ expect(default_options[:namespace]).to eq('rack:session')
20
+ end
21
+
22
+ describe 'when initializing with the redis sub-hash options' do
23
+ let :options do
24
+ {
25
+ key: random_string,
26
+ secret: random_string,
27
+ redis: {
28
+ host: 'hosty.local',
29
+ port: 16_379,
30
+ db: 2,
31
+ key_prefix: 'myapp:session:',
32
+ expire_after: 60 * 120
33
+ }
34
+ }
35
+ end
36
+
37
+ it 'creates a redis instance' do
38
+ expect(store.instance_variable_get(:@redis)).to_not be_nil
39
+ end
40
+
41
+ it 'assigns the :host option to @default_options' do
42
+ expect(default_options[:host]).to eq('hosty.local')
43
+ end
44
+
45
+ it 'assigns the :port option to @default_options' do
46
+ expect(default_options[:port]).to eq(16_379)
47
+ end
48
+
49
+ it 'assigns the :db option to @default_options' do
50
+ expect(default_options[:db]).to eq(2)
51
+ end
52
+
53
+ it 'assigns the :key_prefix option to @default_options' do
54
+ expect(default_options[:key_prefix]).to eq('myapp:session:')
55
+ end
56
+
57
+ it 'assigns the :expire_after option to @default_options' do
58
+ expect(default_options[:expire_after]).to eq(60 * 120)
59
+ end
60
+ end
61
+
62
+ describe 'when configured with both :ttl and :expire_after' do
63
+ let(:ttl_seconds) { 60 * 120 }
64
+ let :options do
65
+ {
66
+ key: random_string,
67
+ secret: random_string,
68
+ redis: {
69
+ host: 'hosty.local',
70
+ port: 16_379,
71
+ db: 2,
72
+ key_prefix: 'myapp:session:',
73
+ ttl: ttl_seconds,
74
+ expire_after: nil
75
+ }
76
+ }
77
+ end
78
+
79
+ it 'assigns the :ttl option to @default_options' do
80
+ expect(default_options[:ttl]).to eq(ttl_seconds)
81
+ expect(default_options[:expire_after]).to be_nil
82
+ end
83
+ end
84
+
85
+ describe 'when initializing with top-level redis options' do
86
+ let :options do
87
+ {
88
+ key: random_string,
89
+ secret: random_string,
90
+ host: 'hostersons.local',
91
+ port: 26_379,
92
+ db: 4,
93
+ key_prefix: 'appydoo:session:',
94
+ expire_after: 60 * 60
95
+ }
96
+ end
97
+
98
+ it 'creates a redis instance' do
99
+ expect(store.instance_variable_get(:@redis)).to_not be_nil
100
+ end
101
+
102
+ it 'assigns the :host option to @default_options' do
103
+ expect(default_options[:host]).to eq('hostersons.local')
104
+ end
105
+
106
+ it 'assigns the :port option to @default_options' do
107
+ expect(default_options[:port]).to eq(26_379)
108
+ end
109
+
110
+ it 'assigns the :db option to @default_options' do
111
+ expect(default_options[:db]).to eq(4)
112
+ end
113
+
114
+ it 'assigns the :key_prefix option to @default_options' do
115
+ expect(default_options[:key_prefix]).to eq('appydoo:session:')
116
+ end
117
+
118
+ it 'assigns the :expire_after option to @default_options' do
119
+ expect(default_options[:expire_after]).to eq(60 * 60)
120
+ end
121
+ end
122
+
123
+ describe 'when initializing with existing redis object' do
124
+ let :options do
125
+ {
126
+ key: random_string,
127
+ secret: random_string,
128
+ redis: {
129
+ client: redis_client,
130
+ key_prefix: 'myapp:session:',
131
+ expire_after: 60 * 30
132
+ }
133
+ }
134
+ end
135
+
136
+ let(:redis_client) { double('redis_client') }
137
+
138
+ it 'assigns given redis object to @redis' do
139
+ expect(store.instance_variable_get(:@redis)).to be(redis_client)
140
+ end
141
+
142
+ it 'assigns the :client option to @default_options' do
143
+ expect(default_options[:client]).to be(redis_client)
144
+ end
145
+
146
+ it 'assigns the :key_prefix option to @default_options' do
147
+ expect(default_options[:key_prefix]).to eq('myapp:session:')
148
+ end
149
+
150
+ it 'assigns the :expire_after option to @default_options' do
151
+ expect(default_options[:expire_after]).to eq(60 * 30)
152
+ end
153
+ end
154
+
155
+ describe 'rack 1.45 compatibility' do
156
+ # Rack 1.45 (which Rails 3.2.x depends on) uses the return value of
157
+ # set_session to set the cookie value. See:
158
+ # https://github.com/rack/rack/blob/1.4.5/lib/rack/session/abstract/id.rb
159
+
160
+ let(:env) { double('env') }
161
+ let(:session_id) { 12_345 }
162
+ let(:session_data) { double('session_data') }
163
+ let(:options) { { expire_after: 123 } }
164
+
165
+ context 'when successfully persisting the session' do
166
+ it 'returns the session id' do
167
+ expect(store.send(:set_session, env, session_id, session_data, options))
168
+ .to eq(session_id)
169
+ end
170
+ end
171
+
172
+ context 'when unsuccessfully persisting the session' do
173
+ before do
174
+ allow(store).to receive(:redis).and_raise(Redis::CannotConnectError)
175
+ end
176
+
177
+ it 'returns false' do
178
+ expect(store.send(:set_session, env, session_id, session_data, options))
179
+ .to eq(false)
180
+ end
181
+ end
182
+
183
+ context 'when no expire_after option is given' do
184
+ let(:options) { {} }
185
+
186
+ it 'sets the session value without expiry' do
187
+ expect(store.send(:set_session, env, session_id, session_data, options))
188
+ .to eq(session_id)
189
+ end
190
+ end
191
+
192
+ context 'when redis is down' do
193
+ before do
194
+ allow(store).to receive(:redis).and_raise(Redis::CannotConnectError)
195
+ store.on_redis_down = ->(*_a) { @redis_down_handled = true }
196
+ end
197
+
198
+ it 'returns false' do
199
+ expect(store.send(:set_session, env, session_id, session_data, options))
200
+ .to eq(false)
201
+ end
202
+
203
+ it 'calls the on_redis_down handler' do
204
+ store.send(:set_session, env, session_id, session_data, options)
205
+ expect(@redis_down_handled).to eq(true)
206
+ end
207
+
208
+ context 'when :on_redis_down re-raises' do
209
+ before { store.on_redis_down = ->(e, *) { raise e } }
210
+
211
+ it 'explodes' do
212
+ expect do
213
+ store.send(:set_session, env, session_id, session_data, options)
214
+ end.to raise_error(Redis::CannotConnectError)
215
+ end
216
+ end
217
+ end
218
+ end
219
+
220
+ describe 'checking for session existence' do
221
+ let(:session_id) { 'foo' }
222
+
223
+ before do
224
+ allow(store).to receive(:current_session_id)
225
+ .with(:env).and_return(session_id)
226
+ end
227
+
228
+ context 'when session id is not provided' do
229
+ context 'when session id is nil' do
230
+ let(:session_id) { nil }
231
+ it 'returns false' do
232
+ expect(store.send(:session_exists?, :env)).to eq(false)
233
+ end
234
+ end
235
+
236
+ context 'when session id is empty string' do
237
+ let(:session_id) { '' }
238
+ it 'returns false' do
239
+ allow(store).to receive(:current_session_id).with(:env).and_return('')
240
+ expect(store.send(:session_exists?, :env)).to eq(false)
241
+ end
242
+ end
243
+ end
244
+
245
+ context 'when session id is provided' do
246
+ let(:redis) do
247
+ double('redis').tap do |o|
248
+ allow(store).to receive(:redis).and_return(o)
249
+ end
250
+ end
251
+
252
+ context 'when session id does not exist in redis' do
253
+ it 'returns false' do
254
+ expect(redis).to receive(:exists).with('foo').and_return(false)
255
+ expect(store.send(:session_exists?, :env)).to eq(false)
256
+ end
257
+ end
258
+
259
+ context 'when session id exists in redis' do
260
+ it 'returns true' do
261
+ expect(redis).to receive(:exists).with('foo').and_return(true)
262
+ expect(store.send(:session_exists?, :env)).to eq(true)
263
+ end
264
+ end
265
+
266
+ context 'when redis is down' do
267
+ it 'returns true (fallback to old behavior)' do
268
+ allow(store).to receive(:redis).and_raise(Redis::CannotConnectError)
269
+ expect(store.send(:session_exists?, :env)).to eq(true)
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ describe 'fetching a session' do
276
+ let :options do
277
+ {
278
+ key_prefix: 'customprefix::'
279
+ }
280
+ end
281
+
282
+ let(:fake_key) { 'thisisarediskey' }
283
+
284
+ it 'retrieves the prefixed key from redis' do
285
+ redis = double('redis')
286
+ allow(store).to receive(:redis).and_return(redis)
287
+ allow(store).to receive(:generate_sid).and_return(fake_key)
288
+ expect(redis).to receive(:get).with("#{options[:key_prefix]}#{fake_key}")
289
+
290
+ store.send(:get_session, double('env'), fake_key)
291
+ end
292
+
293
+ context 'when redis is down' do
294
+ before do
295
+ allow(store).to receive(:redis).and_raise(Redis::CannotConnectError)
296
+ allow(store).to receive(:generate_sid).and_return('foop')
297
+ end
298
+
299
+ it 'returns an empty session hash' do
300
+ expect(store.send(:get_session, double('env'), fake_key).last)
301
+ .to eq({})
302
+ end
303
+
304
+ it 'returns a newly generated sid' do
305
+ expect(store.send(:get_session, double('env'), fake_key).first)
306
+ .to eq('foop')
307
+ end
308
+
309
+ context 'when :on_redis_down re-raises' do
310
+ before { store.on_redis_down = ->(e, *) { raise e } }
311
+
312
+ it 'explodes' do
313
+ expect do
314
+ store.send(:get_session, double('env'), fake_key)
315
+ end.to raise_error(Redis::CannotConnectError)
316
+ end
317
+ end
318
+ end
319
+ end
320
+
321
+ describe 'destroying a session' do
322
+ context 'when the key is in the cookie hash' do
323
+ let(:env) { { 'rack.request.cookie_hash' => cookie_hash } }
324
+ let(:cookie_hash) { double('cookie hash') }
325
+ let(:fake_key) { 'thisisarediskey' }
326
+
327
+ before do
328
+ allow(cookie_hash).to receive(:[]).and_return(fake_key)
329
+ end
330
+
331
+ it 'deletes the prefixed key from redis' do
332
+ redis = double('redis')
333
+ allow(store).to receive(:redis).and_return(redis)
334
+ expect(redis).to receive(:del)
335
+ .with("#{options[:key_prefix]}#{fake_key}")
336
+
337
+ store.send(:destroy, env)
338
+ end
339
+
340
+ context 'when redis is down' do
341
+ before do
342
+ allow(store).to receive(:redis).and_raise(Redis::CannotConnectError)
343
+ end
344
+
345
+ it 'returns false' do
346
+ expect(store.send(:destroy, env)).to eq(false)
347
+ end
348
+
349
+ context 'when :on_redis_down re-raises' do
350
+ before { store.on_redis_down = ->(e, *) { raise e } }
351
+
352
+ it 'explodes' do
353
+ expect do
354
+ store.send(:destroy, env)
355
+ end.to raise_error(Redis::CannotConnectError)
356
+ end
357
+ end
358
+ end
359
+ end
360
+
361
+ context 'when destroyed via #destroy_session' do
362
+ it 'deletes the prefixed key from redis' do
363
+ redis = double('redis', setnx: true)
364
+ allow(store).to receive(:redis).and_return(redis)
365
+ sid = store.send(:generate_sid)
366
+ expect(redis).to receive(:del).with("#{options[:key_prefix]}#{sid}")
367
+
368
+ store.send(:destroy_session, {}, sid, nil)
369
+ end
370
+ end
371
+ end
372
+
373
+ describe 'session encoding' do
374
+ let(:env) { double('env') }
375
+ let(:session_id) { 12_345 }
376
+ let(:session_data) { { 'some' => 'data' } }
377
+ let(:options) { {} }
378
+ let(:encoded_data) { Marshal.dump(session_data) }
379
+ let(:redis) { double('redis', set: nil, get: encoded_data) }
380
+ let(:expected_encoding) { encoded_data }
381
+
382
+ before do
383
+ allow(store).to receive(:redis).and_return(redis)
384
+ end
385
+
386
+ shared_examples_for 'serializer' do
387
+ it 'encodes correctly' do
388
+ expect(redis).to receive(:set).with('12345', expected_encoding)
389
+ store.send(:set_session, env, session_id, session_data, options)
390
+ end
391
+
392
+ it 'decodes correctly' do
393
+ expect(store.send(:get_session, env, session_id))
394
+ .to eq([session_id, session_data])
395
+ end
396
+ end
397
+
398
+ context 'marshal' do
399
+ let(:options) { { serializer: :marshal } }
400
+ it_should_behave_like 'serializer'
401
+ end
402
+
403
+ context 'json' do
404
+ let(:options) { { serializer: :json } }
405
+ let(:encoded_data) { '{"some":"data"}' }
406
+
407
+ it_should_behave_like 'serializer'
408
+ end
409
+
410
+ context 'hybrid' do
411
+ let(:options) { { serializer: :hybrid } }
412
+ let(:expected_encoding) { '{"some":"data"}' }
413
+
414
+ context 'marshal encoded data' do
415
+ it_should_behave_like 'serializer'
416
+ end
417
+
418
+ context 'json encoded data' do
419
+ let(:encoded_data) { '{"some":"data"}' }
420
+
421
+ it_should_behave_like 'serializer'
422
+ end
423
+ end
424
+
425
+ context 'custom' do
426
+ let :custom_serializer do
427
+ Class.new do
428
+ def self.load(_value)
429
+ { 'some' => 'data' }
430
+ end
431
+
432
+ def self.dump(_value)
433
+ 'somedata'
434
+ end
435
+ end
436
+ end
437
+
438
+ let(:options) { { serializer: custom_serializer } }
439
+ let(:expected_encoding) { 'somedata' }
440
+
441
+ it_should_behave_like 'serializer'
442
+ end
443
+ end
444
+
445
+ describe 'handling decode errors' do
446
+ context 'when a class is serialized that does not exist' do
447
+ before do
448
+ allow(store).to receive(:redis)
449
+ .and_return(double('redis',
450
+ get: "\x04\bo:\nNonExistentClass\x00",
451
+ del: true))
452
+ end
453
+
454
+ it 'returns an empty session' do
455
+ expect(store.send(:load_session_from_redis, 'whatever')).to be_nil
456
+ end
457
+
458
+ it 'destroys and drops the session' do
459
+ expect(store).to receive(:destroy_session_from_sid)
460
+ .with('wut', drop: true)
461
+ store.send(:load_session_from_redis, 'wut')
462
+ end
463
+
464
+ context 'when a custom on_session_load_error handler is provided' do
465
+ before do
466
+ store.on_session_load_error = lambda do |e, sid|
467
+ @e = e
468
+ @sid = sid
469
+ end
470
+ end
471
+
472
+ it 'passes the error and the sid to the handler' do
473
+ store.send(:load_session_from_redis, 'foo')
474
+ expect(@e).to be_kind_of(StandardError)
475
+ expect(@sid).to eq('foo')
476
+ end
477
+ end
478
+ end
479
+
480
+ context 'when the encoded data is invalid' do
481
+ before do
482
+ allow(store).to receive(:redis)
483
+ .and_return(double('redis', get: "\x00\x00\x00\x00", del: true))
484
+ end
485
+
486
+ it 'returns an empty session' do
487
+ expect(store.send(:load_session_from_redis, 'bar')).to be_nil
488
+ end
489
+
490
+ it 'destroys and drops the session' do
491
+ expect(store).to receive(:destroy_session_from_sid)
492
+ .with('wut', drop: true)
493
+ store.send(:load_session_from_redis, 'wut')
494
+ end
495
+
496
+ context 'when a custom on_session_load_error handler is provided' do
497
+ before do
498
+ store.on_session_load_error = lambda do |e, sid|
499
+ @e = e
500
+ @sid = sid
501
+ end
502
+ end
503
+
504
+ it 'passes the error and the sid to the handler' do
505
+ store.send(:load_session_from_redis, 'foo')
506
+ expect(@e).to be_kind_of(StandardError)
507
+ expect(@sid).to eq('foo')
508
+ end
509
+ end
510
+ end
511
+ end
512
+
513
+ describe 'validating custom handlers' do
514
+ %w(on_redis_down on_session_load_error).each do |h|
515
+ context 'when nil' do
516
+ it 'does not explode at init' do
517
+ expect { store }.to_not raise_error
518
+ end
519
+ end
520
+
521
+ context 'when callable' do
522
+ let(:options) { { "#{h}": ->(*) { true } } }
523
+
524
+ it 'does not explode at init' do
525
+ expect { store }.to_not raise_error
526
+ end
527
+ end
528
+
529
+ context 'when not callable' do
530
+ let(:options) { { "#{h}": 'herpderp' } }
531
+
532
+ it 'explodes at init' do
533
+ expect { store }.to raise_error(ArgumentError)
534
+ end
535
+ end
536
+ end
537
+ end
538
+
539
+ describe 'setting the session' do
540
+ it 'allows changing the session' do
541
+ env = { 'rack.session.options' => {} }
542
+ sid = 1234
543
+ allow(store).to receive(:redis).and_return(Redis.new)
544
+ data1 = { 'foo' => 'bar' }
545
+ store.send(:set_session, env, sid, data1)
546
+ data2 = { 'baz' => 'wat' }
547
+ store.send(:set_session, env, sid, data2)
548
+ _, session = store.send(:get_session, env, sid)
549
+ expect(session).to eq(data2)
550
+ end
551
+
552
+ it 'allows changing the session when the session has an expiry' do
553
+ env = { 'rack.session.options' => { expire_after: 60 } }
554
+ sid = 1234
555
+ allow(store).to receive(:redis).and_return(Redis.new)
556
+ data1 = { 'foo' => 'bar' }
557
+ store.send(:set_session, env, sid, data1)
558
+ data2 = { 'baz' => 'wat' }
559
+ store.send(:set_session, env, sid, data2)
560
+ _, session = store.send(:get_session, env, sid)
561
+ expect(session).to eq(data2)
562
+ end
563
+ end
564
+ end
@@ -0,0 +1,4 @@
1
+ require 'simplecov'
2
+ require_relative 'support'
3
+ require 'fakeredis/rspec'
4
+ require 'redis-session-store'
data/spec/support.rb ADDED
@@ -0,0 +1,51 @@
1
+ unless defined?(Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY)
2
+ module Rack
3
+ module Session
4
+ module Abstract
5
+ ENV_SESSION_OPTIONS_KEY = 'rack.session.options'.freeze
6
+ end
7
+ end
8
+ end
9
+ end
10
+
11
+ unless defined?(ActionDispatch::Session::AbstractStore)
12
+ module ActionDispatch
13
+ module Session
14
+ class AbstractStore
15
+ ENV_SESSION_OPTIONS_KEY = 'rack.session.options'.freeze
16
+ DEFAULT_OPTIONS = {
17
+ key: '_session_id',
18
+ path: '/',
19
+ domain: nil,
20
+ expire_after: nil,
21
+ secure: false,
22
+ httponly: true,
23
+ cookie_only: true
24
+ }.freeze
25
+
26
+ def initialize(app, options = {})
27
+ @app = app
28
+ @default_options = DEFAULT_OPTIONS.dup.merge(options)
29
+ @key = @default_options[:key]
30
+ @cookie_only = @default_options[:cookie_only]
31
+ end
32
+
33
+ private
34
+
35
+ def generate_sid
36
+ rand(999..9999).to_s(16)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ unless defined?(Rails)
44
+ require 'logger'
45
+
46
+ module Rails
47
+ def self.logger
48
+ @logger ||= Logger.new('/dev/null')
49
+ end
50
+ end
51
+ end