rack-state 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,187 @@
1
+ = Rack::State -- Secure & Modular State Management
2
+
3
+ Rack::State is middleware that securely stores and manages object state.
4
+
5
+ Applications use the Manager API to _set_, _get_, and _delete_ state of
6
+ any object that requires persistence in a stateless environment.
7
+
8
+ == The Core Components
9
+
10
+ * Rack::State
11
+ * Rack::State::Manager
12
+ * Rack::State::Store
13
+
14
+ == Diagram of All the Players
15
+
16
+ State (middleware)
17
+ |
18
+ +--> Manager (env[rack.state.KEY]) <-- API (get/set/delete object)
19
+ |
20
+ +--> client-side: cookie (KEY = token)
21
+ |
22
+ +--> server-side: Store (token, object)
23
+ |
24
+ +--> Memory
25
+ +--> File
26
+ +--> Postgres
27
+ +--> PreparedPostgres
28
+
29
+ The *KEY* and Store *adapter* may be set for each instance of middleware.
30
+
31
+ == Design & Motivation
32
+
33
+ === Primary goal
34
+
35
+ State management for multiple objects with independent control of the
36
+ visibility, security, and storage of each object's state.
37
+
38
+ === But we have Rack::Session
39
+
40
+ Rack::Session provides state management, but does so using only a single
41
+ SessionHash, which is statically located in Rack's environment. The drawback is
42
+ the lack of fine-grained control, efficiency, and multiple instances. This
43
+ single hash-like object is always available to the entire application whether it
44
+ is needed or not.
45
+
46
+ I attempted to modify and bend Rack::Session to meet my design goal, but there
47
+ seemed to be no clean and easy solution. Rack::State was born...
48
+
49
+ === Use Case
50
+
51
+ An application has the following areas that require data persistence:
52
+
53
+ 1. site-wide personalization -- <i>real name, theme, etc.</i>
54
+ 2. blog activity tracking -- <i>articles read, favorites, shares</i>
55
+ 3. secure store -- <i>TLS for entire store, shopping cart, and checkout</i>
56
+
57
+ Three instances of Rack::State can be used. The domain, path, expiration, token
58
+ security (i.e., HttpOnly & Secure cookie flags) and storage backend can be set
59
+ appropriately for each area.
60
+
61
+ For example, site-wide personalization may set path to "/" with a long
62
+ expiration, but the secure store could use "/store" with expiration at end of
63
+ session and set the HttpOnly and Secure flags as well. Additionally, site-wide
64
+ personalization and blog activity tracking states could be stored in the
65
+ database while the secure store state could be saved in files.
66
+
67
+ == Usage Examples
68
+
69
+ === Manage a single state in memory
70
+
71
+ Here's a simple rackup (config.ru) file that uses the Rack::State middleware
72
+ with default options.
73
+
74
+ require 'rack'
75
+ require 'rack/state'
76
+
77
+ use Rack::State
78
+
79
+ run MyApp
80
+
81
+ The application can access the State::Manager via the environment. The following
82
+ code sets up Rack::State to function similarly to Rack::Session.
83
+
84
+ def session
85
+ env['rack.state.token'].get or env['rack.state.token'].set Hash.new
86
+ end
87
+
88
+ session['username']
89
+
90
+
91
+ === Multiple state management with different storage adapters
92
+
93
+ Below, the _store_ state will use the default Memory store and _blog_ will store
94
+ state in files under "tmp/sessions" in the project's root. Additionally, if
95
+ "/store" requires encryption it is wise to secure the token.
96
+
97
+ use Rack::State, key: 'store', path: '/store', secure: true
98
+ use Rack::State, key: 'blog', path: '/blog', max_age: 60*60*24*30,
99
+ store: Rack::State::Store::File.new('tmp/sessions')
100
+
101
+ Then on the application side you may have some helper methods like this:
102
+
103
+ def store
104
+ env['rack.state.store']
105
+ end
106
+
107
+ store.set SecureStore.new # start shopping session
108
+ store.get.cart.add :item7 # buy some stuff
109
+ store.delete # remove from client and server after checkout
110
+
111
+ def blog
112
+ env['rack.state.blog'].get or env['rack.state.blog'].set BlogTracker.new
113
+ end
114
+
115
+ blog.viewed << :article9
116
+ blog.shared << :article5
117
+
118
+
119
+ === Choose a specific storage adapter depending on the environment
120
+
121
+ Here we use the Postgres store for production, otherwise use the default Memory
122
+ store for development and testing.
123
+
124
+ if ENV['RACK_ENV'] == 'production'
125
+ DB = PG::Connection.new
126
+ use Rack::State, store: Rack::State::Store::Postgres.new(DB)
127
+ else
128
+ use Rack::State
129
+ end
130
+
131
+
132
+ == Install, Test & Contribute
133
+
134
+ Install the gem:
135
+
136
+ $ sudo gem install rack-state
137
+
138
+ Or clone the project:
139
+
140
+ TODO
141
+
142
+ State is tested with Christian Neukirchen's awesome test framework,
143
+ {Bacon}[https://github.com/chneukirchen/bacon].
144
+ Get some bacon and start cooking:
145
+
146
+ $ sudo gem install bacon
147
+
148
+ Run the entire test suite from the project's root:
149
+
150
+ $ bacon -a
151
+
152
+ To test a specific component, such as a new storage adapter, run:
153
+
154
+ $ bacon -Ilib spec/spec_COMPONENT.rb
155
+
156
+ After you fix a bug or develop a storage adapter, submit your code and tests.
157
+
158
+ TODO
159
+
160
+ == Compatibility
161
+
162
+ Rack::State was developed and tested on
163
+ OpenBSD[http://www.openbsd.org] 5.3
164
+ using Ruby[https://www.ruby-lang.org] 1.9.3
165
+ and Rack[https://github.com/rack/rack] 1.5.
166
+
167
+ == History
168
+
169
+ * September 5, 2013: Initial design, development and testing.
170
+
171
+ * September 22, 2013: First public release 0.0.1.
172
+
173
+ == Copyright
174
+
175
+ Copyright (c) 2013, Clint Pachl <http://clint.pachl.us>
176
+
177
+ Permission to use, copy, modify, and/or distribute this software for any purpose
178
+ with or without fee is hereby granted, provided that the above copyright notice
179
+ and this permission notice appear in all copies.
180
+
181
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
182
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
183
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
184
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
185
+ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
186
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
187
+ THIS SOFTWARE.
@@ -0,0 +1,392 @@
1
+ # :title: Rack::State -- Secure & Modular State Management
2
+ # :main: README.rdoc
3
+
4
+ require 'securerandom'
5
+
6
+ module Rack
7
+
8
+ #
9
+ # == The Middleware Interface
10
+ #
11
+ # Stores and manages the state of a single object on the server and sets the
12
+ # state token in a client cookie. Multiple State instances may be utilized to
13
+ # manage multiple states, each with different storage adapters and options.
14
+ #
15
+ # State initializes Manager, which contains the API.
16
+ #
17
+ class State
18
+
19
+ VERSION = '0.0.1'
20
+
21
+ #
22
+ # == Middleware Options
23
+ #
24
+ # [store]
25
+ # Instance of the storage adapter. See Rack::State::Store for available
26
+ # adapters. The default is Store::Memory.
27
+ # [key]
28
+ # Client cookie name and Rack environment key suffix. For example, the key
29
+ # "token" sets a cookie named "token" and the environment key to
30
+ # "rack.state.token". Therefore, the application can access the
31
+ # State::Manager instance at <tt>env["rack.state.token"]</tt>.
32
+ # The default is "token".
33
+ # [domain, path, max_age, expires, secure, httponly]
34
+ # Standard cookie options supported by Rack. None are set by default;
35
+ # therefore, the state will be valid for the session only on the domain
36
+ # and path in which it was set and available over unsecured connections.
37
+ #
38
+ def initialize(app, options = {})
39
+ @app = app
40
+ @options = options
41
+ @store = options.delete(:store) || Store::Memory.new
42
+ @key = options.delete(:key) || 'token'
43
+ @skey = "rack.state.#{@key}"
44
+ end
45
+
46
+ def call(env)
47
+ token = Request.new(env).cookies.fetch(@key, nil)
48
+ state = env[@skey] = Manager.new(@store, token, @key, @options)
49
+ status, headers, body = @app.call(env)
50
+ resp = Response.new(body, status, headers)
51
+ state.finish(resp)
52
+ resp.finish
53
+ end
54
+
55
+ #
56
+ # == The Application Interface
57
+ #
58
+ # Orchestrates the state management of an object. The API is very simple:
59
+ # you can #get, #set, and #delete an object.
60
+ #
61
+ # The application can access Manager via the Rack environment.
62
+ # See the *key* middleware option in State.new.
63
+ #
64
+ class Manager
65
+
66
+ #
67
+ # Instantiated in middleware-land.
68
+ #
69
+ # [store]
70
+ # Storage instance that implements CRUD methods for storing state.
71
+ # [token]
72
+ # Used to reference the state object in store. The token is stored as
73
+ # the cookie value on the client. If nil, token not set on client.
74
+ # [key]
75
+ # Cookie name.
76
+ # [options]
77
+ # Cookie options.
78
+ #
79
+ def initialize(store, token, key, options) #:nodoc:
80
+ @object = nil
81
+ @key = key
82
+ @store = store
83
+ @token = token
84
+ @options = options
85
+ end
86
+
87
+ # Return object if it exists in store, otherwise nil.
88
+ def get
89
+ object
90
+ end
91
+
92
+ # Save object in store and set associated token on client.
93
+ def set(object)
94
+ @object = object
95
+ end
96
+
97
+ # Remove object from store and delete associated token on client.
98
+ def delete
99
+ set false
100
+ end
101
+
102
+ #
103
+ # Create, update, or delete the state in the store and set or delete the
104
+ # client's cookie accordingly.
105
+ #
106
+ # Note: middleware method not intended for application use.
107
+ #
108
+ # [resp] Rack::Response used to set and delete the client cookie.
109
+ #
110
+ def finish(resp) #:nodoc:
111
+ if token? && object?
112
+ update_state(resp)
113
+ elsif object?
114
+ create_state(resp)
115
+ elsif token? && deleted?
116
+ delete_state(resp)
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ attr_reader :store, :key, :options
123
+
124
+ def token(generate = false)
125
+ @token = generate ? SecureRandom.urlsafe_base64 : @token
126
+ end
127
+
128
+ def token?
129
+ ! @token.nil?
130
+ end
131
+
132
+ # Load object from store on initial access only.
133
+ def object
134
+ @object ||= token? ? store.read(token) : nil
135
+ end
136
+
137
+ # Returns true if object has been set directly or loaded from store.
138
+ def object?
139
+ !!@object
140
+ end
141
+
142
+ # The object has been marked for deletion.
143
+ def deleted?
144
+ @object == false
145
+ end
146
+
147
+ def create_state(resp)
148
+ store.create(token(true), object)
149
+ resp.set_cookie(key, options.merge(value: token))
150
+ rescue KeyError # token exists
151
+ retry
152
+ end
153
+
154
+ def update_state(resp)
155
+ store.update(token, object)
156
+ resp.set_cookie(key, options.merge(value: token)) # update timestamp
157
+ rescue KeyError # token nonexistent
158
+ create_state(resp)
159
+ end
160
+
161
+ def delete_state(resp)
162
+ store.delete(token)
163
+ resp.delete_cookie(key, options)
164
+ end
165
+ end
166
+
167
+ #
168
+ # Storage adapters are used by Manager for storing object state.
169
+ #
170
+ # == Available Adapters
171
+ #
172
+ # * File
173
+ # * Memory
174
+ # * Postgres
175
+ # * PreparedPostgres
176
+ #
177
+ # == Adapters Must Implement CRUD Methods
178
+ #
179
+ # [create(token, object)]
180
+ # Create +object+ at +token+. Raise KeyError if token exists.
181
+ #
182
+ # [read(token)]
183
+ # Read +object+ at +token+. Return nil if token is nonexistent.
184
+ #
185
+ # [update(token, object)]
186
+ # Update +object+ at +token+. Raise KeyError if token is nonexistent.
187
+ #
188
+ # [delete(token)]
189
+ # Delete +object+ at +token+. Return nil if token is nonexistent.
190
+ #
191
+ # == Adapters Must Conform to Specification
192
+ #
193
+ # All adapters must pass the specifications in spec/state_store.rb to
194
+ # guarantee compatibility with Manager.
195
+ #
196
+ module Store
197
+
198
+ #
199
+ # The default state storage adapter: a slight subclassing of Hash.
200
+ # Memory is only suitable for development and single process apps.
201
+ #
202
+ class Memory < Hash
203
+ def create(token, object)
204
+ raise KeyError if has_key? token
205
+ store(token, object)
206
+ end
207
+
208
+ def read(token)
209
+ fetch(token, nil)
210
+ end
211
+
212
+ def update(token, object)
213
+ raise KeyError unless has_key? token
214
+ store(token, object)
215
+ end
216
+ end
217
+
218
+ #
219
+ # File-based state storage adapter. The state token is the file name
220
+ # and the state object is Marshaled and stored in the file.
221
+ #
222
+ class File
223
+
224
+ require 'tmpdir'
225
+
226
+ #
227
+ # [directory]
228
+ # Storage directory for state files. It must exist and have proper
229
+ # permissions.
230
+ #
231
+ def initialize(directory = Dir.tmpdir)
232
+ @directory = directory
233
+ end
234
+
235
+ def create(token, object)
236
+ flags = ::File::WRONLY|::File::CREAT|::File::EXCL
237
+
238
+ ::File.open(state_file(token), flags, 0600) do |f|
239
+ f.flock ::File::LOCK_EX
240
+ f.write ::Marshal.dump(object)
241
+ end
242
+ rescue Errno::EEXIST
243
+ raise KeyError
244
+ end
245
+
246
+ def read(token)
247
+ ::File.open(state_file(token)) do |f|
248
+ f.flock ::File::LOCK_SH
249
+ ::Marshal.load(f) if f.size > 0
250
+ end rescue nil
251
+ end
252
+
253
+ def update(token, object)
254
+ flags = ::File::WRONLY|::File::TRUNC
255
+
256
+ ::File.open(state_file(token), flags) do |f|
257
+ f.flock ::File::LOCK_EX
258
+ f.write ::Marshal.dump(object)
259
+ end
260
+ rescue Errno::ENOENT
261
+ raise KeyError
262
+ end
263
+
264
+ def delete(token)
265
+ ::File.unlink state_file(token) rescue nil
266
+ end
267
+
268
+ private
269
+
270
+ def state_file(token)
271
+ ::File.join(@directory, token)
272
+ end
273
+ end
274
+
275
+ #
276
+ # Postgres table-based state storage adapter.
277
+ #
278
+ # The following statement will create the required database table:
279
+ #
280
+ # CREATE UNLOGGED TABLE state (
281
+ # token varchar PRIMARY KEY,
282
+ # object bytea NOT NULL,
283
+ # mtime timestamp DEFAULT current_timestamp
284
+ # )
285
+ #
286
+ # The _mtime_ timestamp column may be used for implementing state expiry
287
+ # external to this class. For example, the following statement could be
288
+ # piped to psql(1) daily via cron(8):
289
+ #
290
+ # DELETE FROM state WHERE (mtime + interval '30d') < current_timestamp
291
+ #
292
+ class Postgres
293
+
294
+ # Indicate binary data; the object is stored in binary format.
295
+ BINARY = 1
296
+
297
+ #
298
+ # [connection]
299
+ # Instance of the database connection, e.g., PG::Connection.new
300
+ # [table]
301
+ # Table name where state is stored.
302
+ #
303
+ def initialize(connection, table = 'state')
304
+ @db = connection
305
+ @table = table
306
+ end
307
+
308
+ def create(token, object)
309
+ sql = "INSERT INTO #{@table} (token,object) VALUES ($1,$2)"
310
+ params = [token, {value: ::Marshal.dump(object), format: BINARY}]
311
+ @db.exec_params(sql, params).clear
312
+ rescue PG::UniqueViolation
313
+ raise KeyError
314
+ end
315
+
316
+ def read(token)
317
+ sql = "SELECT object FROM #{@table} WHERE token = $1"
318
+ @db.exec_params(sql, [token], BINARY) do |result|
319
+ ::Marshal.load result.getvalue(0,0)
320
+ end rescue nil
321
+ end
322
+
323
+ def update(token, object)
324
+ sql = "UPDATE #{@table} SET object = $2 WHERE token = $1"
325
+ params = [token, {value: ::Marshal.dump(object), format: BINARY}]
326
+ @db.exec_params(sql, params) do |result|
327
+ raise KeyError if result.cmd_tuples == 0
328
+ end
329
+ end
330
+
331
+ def delete(token)
332
+ sql = "DELETE FROM #{@table} WHERE token = $1"
333
+ @db.exec_params(sql, [token]).clear
334
+ end
335
+ end
336
+
337
+ #
338
+ # Postgres table-based state storage adapter using prepared statements.
339
+ # Because accessing state is a highly repetitive activity, prepared
340
+ # statements may offer some optimization.
341
+ #
342
+ # See Postgres for details.
343
+ #
344
+ class PreparedPostgres
345
+ BINARY = 1
346
+
347
+ def initialize(connection, table = 'state')
348
+ @db = connection
349
+ begin
350
+ @db.prepare 'create_state',
351
+ "INSERT INTO #{table} (token,object) VALUES ($1,$2)"
352
+ @db.prepare 'read_state',
353
+ "SELECT object FROM #{table} WHERE token=$1"
354
+ @db.prepare 'update_state',
355
+ "UPDATE #{table} SET object=$2, mtime=NOW() WHERE token=$1"
356
+ @db.prepare 'delete_state',
357
+ "DELETE FROM #{table} WHERE token=$1"
358
+ rescue PG::DuplicatePstatement
359
+ end
360
+ end
361
+
362
+ def create(token, object)
363
+ @db.exec_prepared('create_state', params(token, object)).clear
364
+ rescue PG::UniqueViolation
365
+ raise KeyError
366
+ end
367
+
368
+ def read(token)
369
+ @db.exec_prepared('read_state', params(token), BINARY) do |result|
370
+ ::Marshal.load result.getvalue(0,0)
371
+ end rescue nil
372
+ end
373
+
374
+ def update(token, object)
375
+ @db.exec_prepared('update_state', params(token, object)) do |result|
376
+ raise KeyError if result.cmd_tuples == 0
377
+ end
378
+ end
379
+
380
+ def delete(token)
381
+ @db.exec_prepared('delete_state', params(token)).clear
382
+ end
383
+
384
+ private
385
+
386
+ def params(token, obj = nil)
387
+ obj ? [token, {value: ::Marshal.dump(obj), format:BINARY}] : [token]
388
+ end
389
+ end
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,174 @@
1
+ require 'rack/state'
2
+ require 'rack/mock'
3
+
4
+ module StateMiddlewareHelpers #:nodoc:
5
+ def valid_token
6
+ lambda { |obj|
7
+ name = obj.split('=')[0]
8
+ obj.match /^#{name}=[^;]{22,}$/
9
+ }
10
+ end
11
+
12
+ def expired_token
13
+ lambda { |obj|
14
+ name = obj.split('=')[0]
15
+ obj.match /^#{name}=; max-age=0; expires=Thu, 01 Jan 1970/
16
+ }
17
+ end
18
+
19
+ def get_state(key, token)
20
+ @req.get("/get_state?key=#{key}", {'HTTP_COOKIE' => token})['Set-Cookie']
21
+ end
22
+
23
+ def set_state(key, token = nil)
24
+ cookie = token ? {'HTTP_COOKIE' => token} : {}
25
+ @req.get("/set_state?key=#{key}", cookie)['Set-Cookie']
26
+ end
27
+
28
+ def del_state(key, token)
29
+ @req.get("/del_state?key=#{key}", {'HTTP_COOKIE' => token})['Set-Cookie']
30
+ end
31
+
32
+ def mock_app
33
+ Proc.new do |env|
34
+ req = Rack::Request.new(env)
35
+ key = req.params['key'] || 'token'
36
+ skey = 'rack.state.' + key
37
+ data = nil
38
+
39
+ case req.path
40
+ when '/get_state'
41
+ data = env[skey].get
42
+ when '/set_state'
43
+ data = env[skey].set(key + 'data')
44
+ when '/del_state'
45
+ env[skey].delete
46
+ end
47
+
48
+ Rack::Response.new(data.to_s).to_a
49
+ end
50
+ end
51
+ end
52
+
53
+ describe Rack::State do
54
+
55
+ describe 'Default Options' do
56
+ extend StateMiddlewareHelpers
57
+
58
+ before do
59
+ @key = 'token' # default key name
60
+ @req = Rack::MockRequest.new(Rack::State.new(mock_app))
61
+ end
62
+
63
+ should 'not return token if state object not accessed' do
64
+ @req.get('/')['Set-Cookie'].should.be.nil
65
+ end
66
+
67
+ should 'return token when setting state' do
68
+ set_state(@key).should.be.a valid_token
69
+ end
70
+
71
+ should 'return previous token' do
72
+ token1 = set_state(@key)
73
+ token2 = get_state(@key, token1)
74
+ token1.should.equal token2
75
+ end
76
+
77
+ should 'not return token after deleting state' do
78
+ token1 = set_state(@key)
79
+ token2 = del_state(@key, token1)
80
+ token3 = get_state(@key, token1)
81
+ token1.should.be.a valid_token
82
+ token2.should.be.an expired_token
83
+ token3.should.be.nil
84
+ end
85
+
86
+ should 'return no token when getting state with nonexistent token' do
87
+ get_state(@key, "#{@key}=nonexistent").should.be.nil
88
+ end
89
+
90
+ should 'return new token when setting state with nonexistent token' do
91
+ token = set_state(@key, "#{@key}=nonexistent")
92
+ token.should.not.equal "#{@key}=nonexistent"
93
+ token.should.be.a valid_token
94
+ end
95
+
96
+ should 'expire token when deleting state with nonexistent token' do
97
+ token = del_state(@key, "#{@key}=nonexistent")
98
+ token.should.not.equal "#{@key}=nonexistent"
99
+ token.should.be.an expired_token
100
+ end
101
+ end
102
+
103
+ describe 'Two Instances Each With Different Stores' do
104
+ require 'tmpdir'
105
+ extend StateMiddlewareHelpers
106
+
107
+ before do
108
+ fstore = Rack::State::Store::File.new(@dir = Dir.mktmpdir)
109
+ mstore = Rack::State::Store::Memory.new
110
+ app = Rack::State.new(mock_app, key:'f', store:fstore)
111
+ app = Rack::State.new(app, key:'m', store:mstore)
112
+ @req = Rack::MockRequest.new(app)
113
+ end
114
+
115
+ after do
116
+ FileUtils.remove_dir @dir
117
+ end
118
+
119
+ should 'return persistent data via response body on subsequent requests' do
120
+ fres = @req.get('/set_state?key=f')
121
+ mres = @req.get('/set_state?key=m')
122
+ fres.body.should.equal 'fdata'
123
+ mres.body.should.equal 'mdata'
124
+ fres = @req.get('/get_state?key=f', 'HTTP_COOKIE' => fres['Set-Cookie'])
125
+ mres = @req.get('/get_state?key=m', 'HTTP_COOKIE' => mres['Set-Cookie'])
126
+ fres.body.should.equal 'fdata'
127
+ mres.body.should.equal 'mdata'
128
+ fres = @req.get('/del_state?key=f', 'HTTP_COOKIE' => fres['Set-Cookie'])
129
+ mres = @req.get('/del_state?key=m', 'HTTP_COOKIE' => mres['Set-Cookie'])
130
+ fres.body.should.be.empty
131
+ mres.body.should.be.empty
132
+ end
133
+
134
+ should 'return tokens when setting state for two different keys' do
135
+ ftoken = set_state('f')
136
+ mtoken = set_state('m')
137
+ ftoken.should.be.a valid_token
138
+ mtoken.should.be.a valid_token
139
+ ftoken.should.not.equal mtoken
140
+ end
141
+
142
+ should 'set states and delete one at a time verifing no token returned' do
143
+ ftoken = set_state('f')
144
+ mtoken = set_state('m')
145
+ del_state('f', ftoken).should.be.an expired_token
146
+ get_state('f', ftoken).should.be.nil
147
+ get_state('m', mtoken).should.be.a valid_token
148
+ del_state('m', mtoken).should.be.an expired_token
149
+ get_state('m', mtoken).should.be.nil
150
+ end
151
+
152
+ should 'return previous token for each state' do
153
+ ft1 = set_state('f')
154
+ mt1 = set_state('m')
155
+ ft2 = get_state('f', ft1)
156
+ mt2 = get_state('m', mt1)
157
+ ft1.should.equal ft2
158
+ mt1.should.equal mt2
159
+ ft2.should.not.equal mt2
160
+ end
161
+
162
+ should 'return new tokens when setting states with nonexistent tokens' do
163
+ ftoken= 'f=nonexistent'
164
+ mtoken= 'm=nonexistent'
165
+ ft = set_state('f', ftoken)
166
+ mt = set_state('m', mtoken)
167
+ ft.should.not.equal ftoken
168
+ ft.should.be.a valid_token
169
+ mt.should.not.equal mtoken
170
+ mt.should.be.a valid_token
171
+ mt.should.not.equal ft
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,23 @@
1
+ require_relative 'state_store'
2
+ require 'tmpdir'
3
+
4
+ describe Rack::State::Store::File do
5
+
6
+ before do
7
+ @dir = Dir.mktmpdir
8
+ @store = Rack::State::Store::File.new(@dir)
9
+ end
10
+
11
+ after do
12
+ FileUtils.remove_dir @dir
13
+ end
14
+
15
+ behaves_like Rack::State::Store
16
+
17
+ it 'creates and deletes token file when creating and deleting state' do
18
+ @store.create(@token, @data)
19
+ File.should.exist File.join(@dir, @token)
20
+ @store.delete(@token)
21
+ File.should.not.exist File.join(@dir, @token)
22
+ end
23
+ end
@@ -0,0 +1,10 @@
1
+ require_relative 'state_store'
2
+
3
+ describe Rack::State::Store::Memory do
4
+
5
+ before do
6
+ @store = Rack::State::Store::Memory.new
7
+ end
8
+
9
+ behaves_like Rack::State::Store
10
+ end
@@ -0,0 +1,43 @@
1
+ #
2
+ # This is more of an integration test that requires a proper environment to run.
3
+ # Therefore, these specs are encapsulated by exception handling to contain a
4
+ # potential explosion!
5
+ #
6
+ # To run these specs, simply:
7
+ # 1. Set appropriate environment variables: PGDATABASE, PGHOST, PGPORT, PGUSER
8
+ # 2. Specify your database password in ~/.pgpass
9
+ #
10
+
11
+ begin
12
+ require 'pg'
13
+ require_relative 'state_store'
14
+
15
+ PGTable = 'state'
16
+ PGDB = PG::Connection.new
17
+ PGDB.exec 'SET client_min_messages TO WARNING'
18
+ PGDB.exec <<-SQL
19
+ CREATE TEMPORARY TABLE #{PGTable} (
20
+ token varchar PRIMARY KEY,
21
+ object bytea NOT NULL,
22
+ mtime timestamp DEFAULT current_timestamp
23
+ )
24
+ SQL
25
+
26
+ describe Rack::State::Store::Postgres do
27
+ before { @store = Rack::State::Store::Postgres.new(PGDB, PGTable) }
28
+ after { PGDB.exec "TRUNCATE TABLE #{PGTable}" }
29
+ behaves_like Rack::State::Store
30
+ end
31
+
32
+ describe Rack::State::Store::PreparedPostgres do
33
+ before { @store = Rack::State::Store::PreparedPostgres.new(PGDB, PGTable) }
34
+ after { PGDB.exec "TRUNCATE TABLE #{PGTable}" }
35
+ behaves_like Rack::State::Store
36
+ end
37
+
38
+ PGDB.close
39
+ rescue LoadError
40
+ $stderr.puts 'Not running Postgres specs: cannot load the "pg" library.'
41
+ rescue PG::ConnectionBad
42
+ $stderr.puts 'Not running Postgres specs: cannot connect to the database.'
43
+ end
@@ -0,0 +1,36 @@
1
+ require 'rack/state'
2
+
3
+ #
4
+ # All Rack::State::Store adapters must include these specifications.
5
+ # Passing these specs guarantees compatibility with StateObject.
6
+ #
7
+ shared Rack::State::Store do
8
+ @token = 'a-valid-token-name'
9
+ @data = 'example state data'
10
+
11
+ it 'returns nil when reading state for a nonexistent token' do
12
+ @store.read(@token).should.be.nil
13
+ end
14
+
15
+ it 'returns nil when deleting state for a nonexistent token' do
16
+ @store.delete(@token).should.be.nil
17
+ end
18
+
19
+ it 'raises KeyError when updating state for nonexistent token' do
20
+ should.raise(KeyError) { @store.update(@token, @data) }
21
+ end
22
+
23
+ it 'raises KeyError when creating state for existing token' do
24
+ should.not.raise(KeyError) { @store.create(@token, @data) }
25
+ should. raise(KeyError) { @store.create(@token, @data) }
26
+ end
27
+
28
+ it 'executes the standard CRUD process as expected' do
29
+ should.not.raise(KeyError) { @store.create(@token, @data) }
30
+ @store.read(@token).should.equal @data
31
+ should.not.raise(KeyError) { @store.update(@token, @data+'more') }
32
+ @store.read(@token).should.equal @data+'more'
33
+ @store.delete(@token)
34
+ @store.read(@token).should.be.nil
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-state
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Clint Pachl
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-09-22 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rack
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '1.5'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '1.5'
30
+ - !ruby/object:Gem::Dependency
31
+ name: bacon
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '1.2'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '1.2'
46
+ description: ! 'Rack::State is middleware that securely stores and manages object
47
+ state.
48
+
49
+
50
+ It is similar to Rack::Session, but provides state management for multiple
51
+
52
+ objects with independent control of the visibility, security, and storage of
53
+
54
+ each object''s state.
55
+
56
+
57
+ Applications use the Manager API to _set_, _get_, and _delete_ state of
58
+
59
+ any object that requires persistence in a stateless environment.
60
+
61
+ '
62
+ email: pachl@ecentryx.com
63
+ executables: []
64
+ extensions: []
65
+ extra_rdoc_files: []
66
+ files:
67
+ - README.rdoc
68
+ - lib/rack/state.rb
69
+ - spec/state_store.rb
70
+ - spec/spec_state.rb
71
+ - spec/spec_state_store_file.rb
72
+ - spec/spec_state_store_memory.rb
73
+ - spec/spec_state_store_postgres.rb
74
+ homepage: https://rubygems.org/gems/rack-state
75
+ licenses:
76
+ - ISC
77
+ post_install_message:
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: 1.9.2
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ! '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubyforge_project:
95
+ rubygems_version: 1.8.23
96
+ signing_key:
97
+ specification_version: 3
98
+ summary: Secure & Modular State Management
99
+ test_files:
100
+ - spec/spec_state.rb
101
+ - spec/spec_state_store_file.rb
102
+ - spec/spec_state_store_memory.rb
103
+ - spec/spec_state_store_postgres.rb