cloudkit 0.9.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.
Files changed (59) hide show
  1. data/CHANGES +2 -0
  2. data/COPYING +20 -0
  3. data/README +55 -0
  4. data/Rakefile +35 -0
  5. data/TODO +22 -0
  6. data/cloudkit.gemspec +82 -0
  7. data/doc/curl.html +329 -0
  8. data/doc/images/example-code.gif +0 -0
  9. data/doc/images/json-title.gif +0 -0
  10. data/doc/images/oauth-discovery-logo.gif +0 -0
  11. data/doc/images/openid-logo.gif +0 -0
  12. data/doc/index.html +87 -0
  13. data/doc/main.css +151 -0
  14. data/doc/rest-api.html +358 -0
  15. data/examples/1.ru +3 -0
  16. data/examples/2.ru +3 -0
  17. data/examples/3.ru +6 -0
  18. data/examples/4.ru +5 -0
  19. data/examples/5.ru +10 -0
  20. data/examples/6.ru +10 -0
  21. data/examples/TOC +17 -0
  22. data/lib/cloudkit.rb +74 -0
  23. data/lib/cloudkit/flash_session.rb +22 -0
  24. data/lib/cloudkit/oauth_filter.rb +273 -0
  25. data/lib/cloudkit/oauth_store.rb +56 -0
  26. data/lib/cloudkit/openid_filter.rb +198 -0
  27. data/lib/cloudkit/openid_store.rb +101 -0
  28. data/lib/cloudkit/rack/builder.rb +120 -0
  29. data/lib/cloudkit/rack/router.rb +20 -0
  30. data/lib/cloudkit/request.rb +159 -0
  31. data/lib/cloudkit/service.rb +135 -0
  32. data/lib/cloudkit/store.rb +459 -0
  33. data/lib/cloudkit/store/adapter.rb +9 -0
  34. data/lib/cloudkit/store/extraction_view.rb +57 -0
  35. data/lib/cloudkit/store/response.rb +51 -0
  36. data/lib/cloudkit/store/response_helpers.rb +72 -0
  37. data/lib/cloudkit/store/sql_adapter.rb +36 -0
  38. data/lib/cloudkit/templates/authorize_request_token.erb +19 -0
  39. data/lib/cloudkit/templates/oauth_descriptor.erb +43 -0
  40. data/lib/cloudkit/templates/oauth_meta.erb +8 -0
  41. data/lib/cloudkit/templates/openid_login.erb +31 -0
  42. data/lib/cloudkit/templates/request_authorization.erb +23 -0
  43. data/lib/cloudkit/templates/request_token_denied.erb +18 -0
  44. data/lib/cloudkit/user_store.rb +44 -0
  45. data/lib/cloudkit/util.rb +60 -0
  46. data/test/ext_test.rb +57 -0
  47. data/test/flash_session_test.rb +22 -0
  48. data/test/helper.rb +50 -0
  49. data/test/oauth_filter_test.rb +331 -0
  50. data/test/oauth_store_test.rb +12 -0
  51. data/test/openid_filter_test.rb +54 -0
  52. data/test/openid_store_test.rb +12 -0
  53. data/test/rack_builder_test.rb +41 -0
  54. data/test/request_test.rb +197 -0
  55. data/test/service_test.rb +718 -0
  56. data/test/store_test.rb +99 -0
  57. data/test/user_store_test.rb +12 -0
  58. data/test/util_test.rb +13 -0
  59. metadata +190 -0
@@ -0,0 +1,12 @@
1
+ require 'helper'
2
+ class OAuthStoreTest < Test::Unit::TestCase
3
+
4
+ context "An OAuthStore" do
5
+
6
+ should "know its version" do
7
+ store = CloudKit::UserStore.new
8
+ assert_equal 1, store.version
9
+ end
10
+
11
+ end
12
+ end
@@ -0,0 +1,54 @@
1
+ require 'helper'
2
+ class OpenIDFilterTest < Test::Unit::TestCase
3
+
4
+ context "An OpenIDFilter" do
5
+
6
+ setup do
7
+ @request = Rack::MockRequest.new(openid_app)
8
+ end
9
+
10
+ should "allow root url pass through" do
11
+ response = @request.get('/')
12
+ assert_equal 200, response.status
13
+ end
14
+
15
+ should "redirect to the login page if authorization is required" do
16
+ response = @request.get('/protected')
17
+ assert_equal 302, response.status
18
+ assert_equal '/login', response['Location']
19
+ end
20
+
21
+ should "notify downstream nodes of its presence" do
22
+ app = Rack::Builder.new do
23
+ use Rack::Session::Pool
24
+ use CloudKit::OpenIDFilter
25
+ run echo_env('cloudkit.via')
26
+ end
27
+ response = Rack::MockRequest.new(app).get('/')
28
+ assert_equal 'cloudkit.filter.openid', response.body
29
+ end
30
+
31
+ context "with upstream authorization middleware" do
32
+
33
+ should "allow pass through if the auth env variable is populated" do
34
+ response = @request.get('/protected', auth)
35
+ assert_equal 200, response.status
36
+ assert_equal remote_user, response.body
37
+ end
38
+
39
+ should "return the auth challenge header" do
40
+ response = @request.get('/protected',
41
+ 'cloudkit.via' => 'cloudkit.filter.oauth',
42
+ 'cloudkit.challenge' => {'WWW-Authenticate' => 'etc.'})
43
+ assert response['WWW-Authenticate']
44
+ end
45
+
46
+ should "return a 401 status if authorization is required" do
47
+ response = @request.get('/protected',
48
+ 'cloudkit.via' => 'cloudkit.filter.oauth',
49
+ 'cloudkit.challenge' => {'WWW-Authenticate' => 'etc.'})
50
+ assert_equal 401, response.status
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,12 @@
1
+ require 'helper'
2
+ class OpenIDStoreTest < Test::Unit::TestCase
3
+
4
+ context "An OpenIDStore" do
5
+
6
+ should "know its version" do
7
+ store = CloudKit::UserStore.new
8
+ assert_equal 1, store.version
9
+ end
10
+
11
+ end
12
+ end
@@ -0,0 +1,41 @@
1
+ require 'helper'
2
+ class RackBuilderTest < Test::Unit::TestCase
3
+
4
+ context "Rack::Builder" do
5
+
6
+ should "expose services" do
7
+ app = Rack::Builder.new do
8
+ expose :items, :things
9
+ run lambda {|app| [200, {}, ['hello']]}
10
+ end
11
+ response = Rack::MockRequest.new(app).get('/items')
12
+ assert_equal 200, response.status
13
+ documents = JSON.parse(response.body)['uris']
14
+ assert_equal [], documents
15
+ end
16
+
17
+ should "expose services with auth using 'contain'" do
18
+ app = Rack::Builder.new do
19
+ contain :items, :things
20
+ run lambda {|app| [200, {}, ['hello']]}
21
+ end
22
+ response = Rack::MockRequest.new(app).get('/items')
23
+ assert_equal 401, response.status
24
+ response = Rack::MockRequest.new(app).get('/things')
25
+ assert_equal 401, response.status
26
+ response = Rack::MockRequest.new(app).get('/')
27
+ assert_equal 200, response.status
28
+ assert_equal 'hello', response.body
29
+ end
30
+
31
+ should "insert a default app if one does not exist" do
32
+ app = Rack::Builder.new { contain :items }
33
+ response = Rack::MockRequest.new(app).get('/items')
34
+ assert_equal 401, response.status
35
+ response = Rack::MockRequest.new(app).get('/')
36
+ assert_equal 200, response.status
37
+ assert response.body.match('CloudKit')
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,197 @@
1
+ require 'helper'
2
+ class RequestTest < Test::Unit::TestCase
3
+
4
+ context "A Request" do
5
+
6
+ should "match requests with routes" do
7
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
8
+ 'http://example.com')).match?(
9
+ 'GET', '/')
10
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
11
+ 'http://example.com/')).match?(
12
+ 'GET', '/')
13
+ assert !CloudKit::Request.new(Rack::MockRequest.env_for(
14
+ 'http://example.com/')).match?(
15
+ 'POST', '/')
16
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
17
+ 'http://example.com/hello')).match?(
18
+ 'GET', '/hello')
19
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
20
+ 'http://example.com/hello')).match?(
21
+ 'GET', '/hello')
22
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
23
+ 'http://example.com/hello', :method => 'POST')).match?(
24
+ 'POST', '/hello')
25
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
26
+ 'http://example.com/hello?q=a', :method => 'POST')).match?(
27
+ 'POST', '/hello', [{'q' => 'a'}])
28
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
29
+ 'http://example.com/hello?q=a', :method => 'POST')).match?(
30
+ 'POST', '/hello', ['q'])
31
+ assert !CloudKit::Request.new(Rack::MockRequest.env_for(
32
+ 'http://example.com/hello?q=a', :method => 'POST')).match?(
33
+ 'POST', '/hello', [{'q' => 'b'}])
34
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
35
+ 'http://example.com/hello?q', :method => 'POST')).match?(
36
+ 'POST', '/hello', [{'q' => nil}])
37
+ assert !CloudKit::Request.new(Rack::MockRequest.env_for(
38
+ 'http://example.com/hello?q=a', :method => 'POST')).match?(
39
+ 'POST', '/hello', [{'q' => nil}])
40
+ assert !CloudKit::Request.new(Rack::MockRequest.env_for(
41
+ 'http://example.com/hello?q=a', :method => 'POST')).match?(
42
+ 'POST', '/hello', [{'q' => ''}])
43
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
44
+ 'http://example.com/hello?q&x=y', :method => 'PUT')).match?(
45
+ 'PUT', '/hello', ['q', {'x' => 'y'}])
46
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
47
+ 'http://example.com/hello?q&x=y&z', :method => 'PUT')).match?(
48
+ 'PUT', '/hello', ['q', {'x' => 'y'}])
49
+ assert !CloudKit::Request.new(Rack::MockRequest.env_for(
50
+ 'http://example.com/hello?q&x=y', :method => 'PUT')).match?(
51
+ 'PUT', '/hello', [{'q' => 'a'},{'x' => 'y'}])
52
+ end
53
+
54
+ should "treat a trailing :id as a wildcard for path matching" do
55
+ assert CloudKit::Request.new(Rack::MockRequest.env_for(
56
+ 'http://example.com/hello/123')).match?('GET', '/hello/:id')
57
+ end
58
+
59
+ should "inject stack-internal via-style env vars" do
60
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/test'))
61
+ assert_equal [], request.via
62
+ request.inject_via('a.b')
63
+ assert request.via.include?('a.b')
64
+ request.inject_via('c.d')
65
+ assert request.via.include?('a.b')
66
+ assert request.via.include?('c.d')
67
+ end
68
+
69
+ should "announce the use of auth middleware" do
70
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
71
+ request.announce_auth('cloudkit.filter.oauth')
72
+ assert request.via.include?('cloudkit.filter.oauth')
73
+ end
74
+
75
+ should "know if auth provided by upstream middleware" do
76
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
77
+ request.announce_auth('cloudkit.filter.oauth')
78
+ assert request.using_auth?
79
+ end
80
+
81
+ should "know the current user" do
82
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
83
+ assert_nil request.current_user
84
+ request = CloudKit::Request.new(
85
+ Rack::MockRequest.env_for('/', 'cloudkit.user' => 'cecil'))
86
+ assert request.current_user
87
+ assert_equal 'cecil', request.current_user
88
+ end
89
+
90
+ should "set the current user" do
91
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
92
+ request.current_user = 'cecil'
93
+ assert request.current_user
94
+ assert_equal 'cecil', request.current_user
95
+ end
96
+
97
+ should "know the login url" do
98
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
99
+ assert_equal '/login', request.login_url
100
+ request = CloudKit::Request.new(
101
+ Rack::MockRequest.env_for(
102
+ '/', 'cloudkit.filter.openid.url.login' => '/sessions'))
103
+ assert_equal '/sessions', request.login_url
104
+ end
105
+
106
+ should "set the login url" do
107
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
108
+ request.login_url = '/welcome'
109
+ assert_equal '/welcome', request.login_url
110
+ end
111
+
112
+ should "know the logout url" do
113
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
114
+ assert_equal '/logout', request.logout_url
115
+ request = CloudKit::Request.new(
116
+ Rack::MockRequest.env_for(
117
+ '/', 'cloudkit.filter.openid.url.logout' => '/sessions'))
118
+ assert_equal '/sessions', request.logout_url
119
+ end
120
+
121
+ should "set the logout url" do
122
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
123
+ request.logout_url = '/goodbye'
124
+ assert_equal '/goodbye', request.logout_url
125
+ end
126
+
127
+ should "get the session" do
128
+ request = CloudKit::Request.new(
129
+ Rack::MockRequest.env_for('/', 'rack.session' => 'this'))
130
+ assert request.session
131
+ assert_equal 'this', request.session
132
+ end
133
+
134
+ should "know the flash" do
135
+ request = CloudKit::Request.new(Rack::MockRequest.env_for(
136
+ '/', 'rack.session' => {}))
137
+ assert request.flash.is_a?(CloudKit::FlashSession)
138
+ end
139
+
140
+ should "parse if-match headers" do
141
+ request = CloudKit::Request.new(Rack::MockRequest.env_for(
142
+ '/items/123/versions'))
143
+ assert_nil request.if_match
144
+ request = CloudKit::Request.new(Rack::MockRequest.env_for(
145
+ '/items/123/versions',
146
+ 'HTTP_IF_MATCH' => '"a"'))
147
+ assert_equal 'a', request.if_match
148
+ end
149
+
150
+ should "treat a list of etags in an if-match header as a single etag" do
151
+ request = CloudKit::Request.new(Rack::MockRequest.env_for(
152
+ '/items/123/versions',
153
+ 'HTTP_IF_MATCH' => '"a", "b"'))
154
+ # See CloudKit::Request#if_match for more info on this expectation
155
+ assert_equal 'a", "b', request.if_match
156
+ end
157
+
158
+ should "ignore if-match when set to *" do
159
+ request = CloudKit::Request.new(Rack::MockRequest.env_for(
160
+ '/items/123/versions',
161
+ 'HTTP_IF_MATCH' => '*'))
162
+ assert_nil request.if_match
163
+ end
164
+
165
+ should "understand header auth" do
166
+ request = CloudKit::Request.new(Rack::MockRequest.env_for(
167
+ 'http://photos.example.net/photos?file=vacation.jpg&size=original',
168
+ 'Authorization' =>
169
+ 'OAuth realm="",' +
170
+ 'oauth_version="1.0",' +
171
+ 'oauth_consumer_key="dpf43f3p2l4k3l03",' +
172
+ 'oauth_token="nnch734d00sl2jdk",' +
173
+ 'oauth_timestamp="1191242096",' +
174
+ 'oauth_nonce="kllo9940pd9333jh",' +
175
+ 'oauth_signature="tR3%2BTy81lMeYAr%2FFid0kMTYa%2FWM%3D",' +
176
+ 'oauth_signature_method="HMAC-SHA1"'))
177
+ assert_equal 'dpf43f3p2l4k3l03', request['oauth_consumer_key']
178
+ assert_equal 'nnch734d00sl2jdk', request['oauth_token']
179
+ assert_equal '1191242096', request['oauth_timestamp']
180
+ assert_equal 'kllo9940pd9333jh', request['oauth_nonce']
181
+ assert_equal 'tR3+Ty81lMeYAr/Fid0kMTYa/WM=', request['oauth_signature']
182
+ assert_equal 'HMAC-SHA1', request['oauth_signature_method']
183
+ end
184
+
185
+ should "know the last path element" do
186
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/'))
187
+ assert_nil request.last_path_element
188
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/abc'))
189
+ assert_equal 'abc', request.last_path_element
190
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/abc/'))
191
+ assert_equal 'abc', request.last_path_element
192
+ request = CloudKit::Request.new(Rack::MockRequest.env_for('/abc/def'))
193
+ assert_equal 'def', request.last_path_element
194
+ end
195
+
196
+ end
197
+ end
@@ -0,0 +1,718 @@
1
+ require 'helper'
2
+ class ServiceTest < Test::Unit::TestCase
3
+
4
+ context "A CloudKit::Service" do
5
+
6
+ setup do
7
+ @request = Rack::MockRequest.new(plain_service)
8
+ end
9
+
10
+ teardown do
11
+ FileUtils.rm_f('service.db')
12
+ end
13
+
14
+ should "return a 501 for unimplemented methods" do
15
+ response = @request.request('TRACE', '/items')
16
+ assert_equal 501, response.status
17
+ response = @request.request('REJUXTAPOSE', '/items')
18
+ assert_equal 501, response.status
19
+ end
20
+
21
+ context "using auth" do
22
+
23
+ setup do
24
+ @store = CloudKit::Store.new(
25
+ :adapter => CloudKit::SQLAdapter.new('sqlite://service.db'),
26
+ :collections => [:items, :things])
27
+ @request = Rack::MockRequest.new(authed_service)
28
+ end
29
+
30
+ should "allow requests for / to pass through" do
31
+ response = @request.get('/')
32
+ assert_equal 'martino', response.body
33
+ end
34
+
35
+ should "allow any non-specified resource request to pass through" do
36
+ response = @request.get('/hammers')
37
+ assert_equal 'martino', response.body
38
+ end
39
+
40
+ should "return a 500 if authentication is configured incorrectly" do
41
+ # simulate auth requirement without the auth_key being set by the
42
+ # auth filter(s)
43
+ response = @request.get('/items')
44
+ assert_equal 500, response.status
45
+ end
46
+
47
+ context "on GET /cloudkit-meta" do
48
+
49
+ setup do
50
+ @response = @request.get('/cloudkit-meta', auth_key => remote_user)
51
+ end
52
+
53
+ should "be successful" do
54
+ assert_equal 200, @response.status
55
+ end
56
+
57
+ should "return a list of hosted collection URIs" do
58
+ uris = JSON.parse(@response.body)['uris']
59
+ assert_same_elements ['/things', '/items'], uris
60
+ end
61
+
62
+ should "return a Content-Type header" do
63
+ assert_equal 'application/json', @response['Content-Type']
64
+ end
65
+
66
+ should "return an ETag" do
67
+ assert @response['ETag']
68
+ end
69
+
70
+ should "not set a Last-Modified header" do
71
+ assert_nil @response['Last-Modified']
72
+ end
73
+ end
74
+
75
+ context "on GET /:collection" do
76
+
77
+ setup do
78
+ 3.times do |i|
79
+ json = JSON.generate(:this => i.to_s)
80
+ @store.put("/items/#{i}", :json => json, :remote_user => remote_user)
81
+ end
82
+ json = JSON.generate(:this => '4')
83
+ @store.put('/items/4', :json => json, :remote_user => 'someoneelse')
84
+ @response = @request.get('/items', auth_key => remote_user)
85
+ @parsed_response = JSON.parse(@response.body)
86
+ end
87
+
88
+ should "be successful" do
89
+ assert_equal 200, @response.status
90
+ end
91
+
92
+ should "return a list of URIs for all owner-originated resources" do
93
+ assert_same_elements ['/items/0', '/items/1', '/items/2'],
94
+ @parsed_response['uris']
95
+ end
96
+
97
+ should "sort descending on last_modified date" do
98
+ assert_equal ['/items/2', '/items/1', '/items/0'],
99
+ @parsed_response['uris']
100
+ end
101
+
102
+ should "return the total number of uris" do
103
+ assert @parsed_response['total']
104
+ assert_equal 3, @parsed_response['total']
105
+ end
106
+
107
+ should "return the offset" do
108
+ assert @parsed_response['offset']
109
+ assert_equal 0, @parsed_response['offset']
110
+ end
111
+
112
+ should "return a Content-Type header" do
113
+ assert_equal 'application/json', @response['Content-Type']
114
+ end
115
+
116
+ should "return an ETag" do
117
+ assert @response['ETag']
118
+ end
119
+
120
+ should "return a Last-Modified date" do
121
+ assert @response['Last-Modified']
122
+ end
123
+
124
+ should "accept a limit parameter" do
125
+ response = @request.get('/items?limit=2', auth_key => remote_user)
126
+ parsed_response = JSON.parse(response.body)
127
+ assert_equal ['/items/2', '/items/1'], parsed_response['uris']
128
+ assert_equal 3, parsed_response['total']
129
+ end
130
+
131
+ should "accept an offset parameter" do
132
+ response = @request.get('/items?offset=1', auth_key => remote_user)
133
+ parsed_response = JSON.parse(response.body)
134
+ assert_equal ['/items/1', '/items/0'], parsed_response['uris']
135
+ assert_equal 1, parsed_response['offset']
136
+ assert_equal 3, parsed_response['total']
137
+ end
138
+
139
+ should "accept combined limit and offset parameters" do
140
+ response = @request.get('/items?limit=1&offset=1', auth_key => remote_user)
141
+ parsed_response = JSON.parse(response.body)
142
+ assert_equal ['/items/1'], parsed_response['uris']
143
+ assert_equal 1, parsed_response['offset']
144
+ assert_equal 3, parsed_response['total']
145
+ end
146
+
147
+ should "return an empty list if no resources are found" do
148
+ response = @request.get('/things', auth_key => remote_user)
149
+ parsed_response = JSON.parse(response.body)
150
+ assert_equal [], parsed_response['uris']
151
+ assert_equal 0, parsed_response['total']
152
+ assert_equal 0, parsed_response['offset']
153
+ end
154
+ end
155
+
156
+ context "on GET /:collection/:id" do
157
+
158
+ setup do
159
+ json = JSON.generate(:this => 'that')
160
+ @store.put('/items/abc', :json => json, :remote_user => remote_user)
161
+ @response = @request.get(
162
+ '/items/abc', 'HTTP_HOST' => 'example.org', auth_key => remote_user)
163
+ end
164
+
165
+ should "be successful" do
166
+ assert_equal 200, @response.status
167
+ end
168
+
169
+ should "return a document for valid owner-originated requests" do
170
+ data = JSON.parse(@response.body)
171
+ assert_equal 'that', data['this']
172
+ end
173
+
174
+ should "return a 404 if a document does not exist" do
175
+ response = @request.get('/items/nothing', auth_key => remote_user)
176
+ assert_equal 404, response.status
177
+ end
178
+
179
+ should "return a Content-Type header" do
180
+ assert_equal 'application/json', @response['Content-Type']
181
+ end
182
+
183
+ should "return an ETag header" do
184
+ assert @response['ETag']
185
+ end
186
+
187
+ should "return a Last-Modified header" do
188
+ assert @response['Last-Modified']
189
+ end
190
+
191
+ should "not return documents for unauthorized users" do
192
+ response = @request.get('/items/abc', auth_key => 'bogus')
193
+ assert_equal 404, response.status
194
+ end
195
+
196
+ should "return a versions link header" do
197
+ assert @response['Link']
198
+ assert @response['Link'].match("<http://example.org/items/abc/versions>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/versions\"")
199
+ end
200
+ end
201
+
202
+ context "on GET /:collection/:id/versions" do
203
+
204
+ setup do
205
+ @etags = []
206
+ 4.times do |i|
207
+ json = JSON.generate(:this => i)
208
+ options = {:json => json, :remote_user => remote_user}
209
+ options.filter_merge!(:etag => @etags.try(:last))
210
+ result = @store.put('/items/abc', options)
211
+ @etags << result.parsed_content['etag']
212
+ end
213
+ @response = @request.get('/items/abc/versions', auth_key => remote_user)
214
+ @parsed_response = JSON.parse(@response.body)
215
+ end
216
+
217
+ should "be successful" do
218
+ assert_equal 200, @response.status
219
+ end
220
+
221
+ should "be successful even if the current resource has been deleted" do
222
+ @store.delete('/items/abc', :etag => @etags.last, :remote_user => remote_user)
223
+ response = @request.get('/items/abc/versions', auth_key => remote_user)
224
+ assert_equal 200, @response.status
225
+ parsed_response = JSON.parse(response.body)
226
+ assert_equal 4, parsed_response['uris'].size
227
+ end
228
+
229
+ should "return a list of URIs for all versions of a resource" do
230
+ uris = @parsed_response['uris']
231
+ assert uris
232
+ assert_equal 4, uris.size
233
+ end
234
+
235
+ should "return a 404 if the resource does not exist" do
236
+ response = @request.get('/items/nothing/versions', auth_key => remote_user)
237
+ assert_equal 404, response.status
238
+ end
239
+
240
+ should "return a 404 for non-owner-originated requests" do
241
+ response = @request.get('/items/abc/versions', auth_key => 'someoneelse')
242
+ assert_equal 404, response.status
243
+ end
244
+
245
+ should "sort descending on last_modified date" do
246
+ assert_equal ['/items/abc'].concat(@etags[0..-2].reverse.map{|e| "/items/abc/versions/#{e}"}),
247
+ @parsed_response['uris']
248
+ end
249
+
250
+ should "return the total number of uris" do
251
+ assert @parsed_response['total']
252
+ assert_equal 4, @parsed_response['total']
253
+ end
254
+
255
+ should "return the offset" do
256
+ assert @parsed_response['offset']
257
+ assert_equal 0, @parsed_response['offset']
258
+ end
259
+
260
+ should "return a Content-Type header" do
261
+ assert_equal 'application/json', @response['Content-Type']
262
+ end
263
+
264
+ should "return an ETag" do
265
+ assert @response['ETag']
266
+ end
267
+
268
+ should "return a Last-Modified date" do
269
+ assert @response['Last-Modified']
270
+ end
271
+
272
+ should "accept a limit parameter" do
273
+ response = @request.get('/items/abc/versions?limit=2', auth_key => remote_user)
274
+ parsed_response = JSON.parse(response.body)
275
+ assert_equal ['/items/abc', "/items/abc/versions/#{@etags[-2]}"],
276
+ parsed_response['uris']
277
+ assert_equal 4, parsed_response['total']
278
+ end
279
+
280
+ should "accept an offset parameter" do
281
+ response = @request.get('/items/abc/versions?offset=1', auth_key => remote_user)
282
+ parsed_response = JSON.parse(response.body)
283
+ assert_equal @etags.reverse[1..-1].map{|e| "/items/abc/versions/#{e}"},
284
+ parsed_response['uris']
285
+ assert_equal 1, parsed_response['offset']
286
+ assert_equal 4, parsed_response['total']
287
+ end
288
+
289
+ should "accept combined limit and offset parameters" do
290
+ response = @request.get('/items/abc/versions?limit=1&offset=1', auth_key => remote_user)
291
+ parsed_response = JSON.parse(response.body)
292
+ assert_equal ["/items/abc/versions/#{@etags[-2]}"], parsed_response['uris']
293
+ assert_equal 1, parsed_response['offset']
294
+ assert_equal 4, parsed_response['total']
295
+ end
296
+ end
297
+
298
+ context "on GET /:collection/:id/versions/:etag" do
299
+
300
+ setup do
301
+ @etags = []
302
+ 2.times do |i|
303
+ json = JSON.generate(:this => i)
304
+ options = {:json => json, :remote_user => remote_user}
305
+ options.filter_merge!(:etag => @etags.try(:last))
306
+ result = @store.put('/items/abc', options)
307
+ @etags << result.parsed_content['etag']
308
+ end
309
+ @response = @request.get(
310
+ "/items/abc/versions/#{@etags.first}", auth_key => remote_user)
311
+ @parsed_response = JSON.parse(@response.body)
312
+ end
313
+
314
+ should "be successful" do
315
+ assert_equal 200, @response.status
316
+ end
317
+
318
+ should "return a document for valid owner-originated requests" do
319
+ assert_equal 0, @parsed_response['this']
320
+ end
321
+
322
+ should "return a 404 if a document is not found" do
323
+ response = @request.get(
324
+ "/items/nothing/versions/#{@etags.first}", auth_key => remote_user)
325
+ assert_equal 404, response.status
326
+ end
327
+
328
+ should "return a Content-Type header" do
329
+ assert_equal 'application/json', @response['Content-Type']
330
+ end
331
+
332
+ should "return an ETag header" do
333
+ assert @response['ETag']
334
+ end
335
+
336
+ should "return a Last-Modified header" do
337
+ assert @response['Last-Modified']
338
+ end
339
+
340
+ should "not return documents for unauthorized users" do
341
+ response = @request.get(
342
+ "/items/abc/versions/#{@etags.first}", auth_key => 'someoneelse')
343
+ assert_equal 404, response.status
344
+ end
345
+ end
346
+
347
+ context "on POST /:collection" do
348
+
349
+ setup do
350
+ json = JSON.generate(:this => 'that')
351
+ @response = @request.post(
352
+ '/items', :input => json, auth_key => remote_user)
353
+ @body = JSON.parse(@response.body)
354
+ end
355
+
356
+ should "store the document" do
357
+ result = @store.get(@body['uri'])
358
+ assert_equal 200, result.status
359
+ end
360
+
361
+ should "return a 201 when successful" do
362
+ assert_equal 201, @response.status
363
+ end
364
+
365
+ should "return the metadata" do
366
+ assert_equal 4, @body.keys.size
367
+ assert_same_elements ['ok', 'uri', 'etag', 'last_modified'], @body.keys
368
+ end
369
+
370
+ should "set the Content-Type header" do
371
+ assert_equal 'application/json', @response['Content-Type']
372
+ end
373
+
374
+ should "not set an ETag header" do
375
+ assert_nil @response['ETag']
376
+ end
377
+
378
+ should "not set a Last-Modified header" do
379
+ assert_nil @response['Last-Modified']
380
+ end
381
+
382
+ should "return a 422 if parsing fails" do
383
+ response = @request.post('/items', :input => 'fail', auth_key => remote_user)
384
+ assert_equal 422, response.status
385
+ end
386
+
387
+ should "insert into its views" do
388
+ view = CloudKit::ExtractionView.new(
389
+ :fruits,
390
+ :observe => :items,
391
+ :extract => [:apple, :lemon])
392
+ store = CloudKit::Store.new(
393
+ :collections => [:items],
394
+ :views => [view])
395
+ json = JSON.generate(:apple => 'green')
396
+ store.put('/items/123', :json => json)
397
+ json = JSON.generate(:apple => 'red')
398
+ store.put('/items/456', :json => json)
399
+ result = store.get('/fruits', :apple => 'green')
400
+ uris = result.parsed_content['uris']
401
+ assert_equal 1, uris.size
402
+ assert uris.include?('/items/123')
403
+ end
404
+ end
405
+
406
+ context "on PUT /:collection/:id" do
407
+
408
+ setup do
409
+ json = JSON.generate(:this => 'that')
410
+ @original = @store.put('/items/abc', :json => json, :remote_user => remote_user)
411
+ etag = @original.parsed_content['etag']
412
+ json = JSON.generate(:this => 'other', :etag => etag)
413
+ @response = @request.put(
414
+ '/items/abc',
415
+ :input => json,
416
+ 'HTTP_IF_MATCH' => etag,
417
+ auth_key => remote_user)
418
+ @json = JSON.parse(@response.body)
419
+ end
420
+
421
+ should "create a document if it does not already exist" do
422
+ json = JSON.generate(:this => 'thing')
423
+ response = @request.put(
424
+ '/items/xyz', :input => json, auth_key => remote_user)
425
+ assert_equal 201, response.status
426
+ result = @store.get('/items/xyz')
427
+ assert_equal 200, result.status
428
+ assert_equal 'thing', result.parsed_content['this']
429
+ end
430
+
431
+ should "update the document if it already exists" do
432
+ assert_equal 200, @response.status
433
+ result = @store.get('/items/abc').parsed_content
434
+ assert_equal 'other', result['this']
435
+ end
436
+
437
+ should "return the metadata" do
438
+ assert_equal 4, @json.keys.size
439
+ assert_same_elements ['ok', 'uri', 'etag', 'last_modified'], @json.keys
440
+ end
441
+
442
+ should "set the Content-Type header" do
443
+ assert_equal 'application/json', @response['Content-Type']
444
+ end
445
+
446
+ should "not set an ETag header" do
447
+ assert_nil @response['ETag']
448
+ end
449
+
450
+ should "not set a Last-Modified header" do
451
+ assert_nil @response['Last-Modified']
452
+ end
453
+
454
+ should "not allow a remote_user change" do
455
+ json = JSON.generate(:this => 'other')
456
+ response = @request.put(
457
+ '/items/abc',
458
+ :input => json,
459
+ 'HTTP_IF_MATCH' => @json['etag'],
460
+ auth_key => 'someone_else')
461
+ assert_equal 404, response.status
462
+ end
463
+
464
+ should "detect and return conflicts" do
465
+ client_a_input = JSON.generate(:this => 'updated')
466
+ client_b_input = JSON.generate(:other => 'thing')
467
+ response = @request.put(
468
+ '/items/abc',
469
+ :input => client_a_input,
470
+ 'HTTP_IF_MATCH' => @json['etag'],
471
+ auth_key => remote_user)
472
+ assert_equal 200, response.status
473
+ response = @request.put(
474
+ '/items/abc',
475
+ :input => client_b_input,
476
+ 'HTTP_IF_MATCH' => @json['etag'],
477
+ auth_key => remote_user)
478
+ assert_equal 412, response.status
479
+ end
480
+
481
+ should "require an ETag for updates" do
482
+ json = JSON.generate(:this => 'updated')
483
+ response = @request.put(
484
+ '/items/abc',
485
+ :input => json,
486
+ auth_key => remote_user)
487
+ assert_equal 400, response.status
488
+ end
489
+
490
+ should "return a 422 if parsing fails" do
491
+ response = @request.put(
492
+ '/items/zzz', :input => 'fail', auth_key => remote_user)
493
+ assert_equal 422, response.status
494
+ end
495
+
496
+ should "version document updates" do
497
+ json = JSON.generate(:this => 'updated')
498
+ response = @request.put(
499
+ '/items/abc',
500
+ :input => json,
501
+ 'HTTP_IF_MATCH' => @json['etag'],
502
+ auth_key => remote_user)
503
+ assert_equal 200, response.status
504
+ etag = JSON.parse(response.body)['etag']
505
+ json = JSON.generate(:this => 'updated again')
506
+ new_response = @request.put(
507
+ '/items/abc',
508
+ :input => json,
509
+ 'HTTP_IF_MATCH' => etag,
510
+ auth_key => remote_user)
511
+ assert_equal 200, new_response.status
512
+ new_etag = JSON.parse(new_response.body)['etag']
513
+ assert_not_equal etag, new_etag
514
+ end
515
+
516
+ should "update its views" do
517
+ view = CloudKit::ExtractionView.new(
518
+ :fruits,
519
+ :observe => :items,
520
+ :extract => [:apple, :lemon])
521
+ store = CloudKit::Store.new(
522
+ :collections => [:items],
523
+ :views => [view])
524
+ json = JSON.generate(:apple => 'green')
525
+ result = store.put('/items/123', :json => json)
526
+ json = JSON.generate(:apple => 'red')
527
+ store.put(
528
+ '/items/123', :etag => result.parsed_content['etag'], :json => json)
529
+ result = store.get('/fruits', :apple => 'green')
530
+ uris = result.parsed_content['uris']
531
+ assert_equal 0, uris.size
532
+ result = store.get('/fruits', :apple => 'red')
533
+ uris = result.parsed_content['uris']
534
+ assert_equal 1, uris.size
535
+ assert uris.include?('/items/123')
536
+ end
537
+ end
538
+
539
+ context "on DELETE /:collection/:id" do
540
+
541
+ setup do
542
+ json = JSON.generate(:this => 'that')
543
+ @result = @store.put('/items/abc', :json => json, :remote_user => remote_user)
544
+ @etag = @result.parsed_content['etag']
545
+ end
546
+
547
+ should "delete the document" do
548
+ response = @request.delete(
549
+ '/items/abc',
550
+ 'HTTP_IF_MATCH' => @etag,
551
+ auth_key => remote_user)
552
+ assert_equal 200, response.status
553
+ result = @store.get('/items/abc')
554
+ assert_equal 410, result.status
555
+ end
556
+
557
+ should "return the metadata" do
558
+ response = @request.delete(
559
+ '/items/abc',
560
+ 'HTTP_IF_MATCH' => @etag,
561
+ auth_key => remote_user)
562
+ json = JSON.parse(response.body)
563
+ assert_equal 4, json.keys.size
564
+ assert_same_elements ['ok', 'uri', 'etag', 'last_modified'], json.keys
565
+ end
566
+
567
+ should "set the Content-Type header" do
568
+ response = @request.delete(
569
+ '/items/abc',
570
+ 'HTTP_IF_MATCH' => @etag,
571
+ auth_key => remote_user)
572
+ assert_equal 'application/json', response['Content-Type']
573
+ end
574
+
575
+ should "not set an ETag header" do
576
+ response = @request.delete(
577
+ '/items/abc',
578
+ 'HTTP_IF_MATCH' => @etag,
579
+ auth_key => remote_user)
580
+ assert_nil response['ETag']
581
+ end
582
+
583
+ should "not set a Last-Modified header" do
584
+ response = @request.delete(
585
+ '/items/abc',
586
+ 'HTTP_IF_MATCH' => @etag,
587
+ auth_key => remote_user)
588
+ assert_nil response['Last-Modified']
589
+ end
590
+
591
+ should "return a 404 for items that have never existed" do
592
+ response = @request.delete(
593
+ '/items/zzz',
594
+ 'HTTP_IF_MATCH' => @etag,
595
+ auth_key => remote_user)
596
+ assert_equal 404, response.status
597
+ end
598
+
599
+ should "require an ETag" do
600
+ response = @request.delete(
601
+ '/items/abc',
602
+ auth_key => remote_user)
603
+ assert_equal 400, response.status
604
+ end
605
+
606
+ should "verify the user in the doc" do
607
+ response = @request.delete(
608
+ '/items/abc',
609
+ 'HTTP_IF_MATCH' => @etag,
610
+ auth_key => 'someoneelse')
611
+ assert_equal 404, response.status
612
+ end
613
+
614
+ should "detect and return conflicts" do
615
+ json = JSON.generate(:this => 'that')
616
+ result = @store.put('/items/123', :json => json, :remote_user => remote_user)
617
+ etag = result.parsed_content['etag']
618
+ client_a_input = JSON.generate(:this => 'updated')
619
+ client_b_input = JSON.generate(:other => 'thing')
620
+ response = @request.put(
621
+ '/items/123',
622
+ :input => client_a_input,
623
+ 'HTTP_IF_MATCH' => etag,
624
+ auth_key => remote_user)
625
+ assert_equal 200, response.status
626
+ response = @request.delete(
627
+ '/items/123',
628
+ :input => client_b_input,
629
+ 'HTTP_IF_MATCH' => etag,
630
+ auth_key => remote_user)
631
+ assert_equal 412, response.status
632
+ end
633
+
634
+ should "retain version history" do
635
+ response = @request.delete(
636
+ '/items/abc',
637
+ 'HTTP_IF_MATCH' => @etag,
638
+ auth_key => remote_user)
639
+ assert_equal 200, response.status
640
+ response = @request.get(
641
+ '/items/abc/versions',
642
+ auth_key => remote_user)
643
+ json = JSON.parse(response.body)
644
+ assert_equal 1, json['total']
645
+ end
646
+
647
+ should "remove records from its views" do
648
+ view = CloudKit::ExtractionView.new(
649
+ :fruits,
650
+ :observe => :items,
651
+ :extract => [:apple, :lemon])
652
+ store = CloudKit::Store.new(
653
+ :collections => [:items],
654
+ :views => [view])
655
+ json = JSON.generate(:apple => 'green')
656
+ result = store.put('/items/123', :json => json)
657
+ store.delete('/items/123', :etag => result.parsed_content['etag'])
658
+ result = store.get('/fruits', :apple => 'green')
659
+ uris = result.parsed_content['uris']
660
+ assert_equal [], uris
661
+ end
662
+ end
663
+
664
+ context "on OPTIONS /:collection" do
665
+
666
+ setup do
667
+ @response = @request.request('OPTIONS', '/items', auth_key => remote_user)
668
+ end
669
+
670
+ should "return a 200 status" do
671
+ assert_equal 200, @response.status
672
+ end
673
+
674
+ should "return a list of available methods" do
675
+ assert @response['Allow']
676
+ methods = @response['Allow'].split(', ')
677
+ assert_same_elements(['GET', 'POST', 'HEAD', 'OPTIONS'], methods)
678
+ end
679
+ end
680
+
681
+ context "on OPTIONS /:collection/:id" do
682
+
683
+ setup do
684
+ @response = @request.request('OPTIONS', '/items/xyz', auth_key => remote_user)
685
+ end
686
+
687
+ should "return a 200 status" do
688
+ assert_equal 200, @response.status
689
+ end
690
+
691
+ should "return a list of available methods" do
692
+ assert @response['Allow']
693
+ methods = @response['Allow'].split(', ')
694
+ assert_same_elements(['GET', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'], methods)
695
+ end
696
+ end
697
+
698
+ context "on OPTIONS /:collection/:id/versions" do
699
+ end
700
+
701
+ context "on OPTIONS /:collection/:id/versions/:etag" do
702
+ end
703
+
704
+ context "on HEAD" do
705
+
706
+ should "return an empty body" do
707
+ json = JSON.generate(:this => 'that')
708
+ @store.put('/items/abc', :json => json, :remote_user => remote_user)
709
+ response = @request.request('HEAD', '/items/abc', auth_key => remote_user)
710
+ assert_equal '', response.body
711
+ response = @request.request('HEAD', '/items', auth_key => remote_user)
712
+ assert_equal '', response.body
713
+ end
714
+
715
+ end
716
+ end
717
+ end
718
+ end