rack-state 0.0.1

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,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