cloudkit-jruby 0.11.2

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 (64) hide show
  1. data/CHANGES +47 -0
  2. data/COPYING +20 -0
  3. data/README +84 -0
  4. data/Rakefile +42 -0
  5. data/TODO +21 -0
  6. data/cloudkit.gemspec +89 -0
  7. data/doc/curl.html +388 -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 +90 -0
  13. data/doc/main.css +151 -0
  14. data/doc/rest-api.html +467 -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 +9 -0
  20. data/examples/6.ru +11 -0
  21. data/examples/TOC +17 -0
  22. data/lib/cloudkit.rb +92 -0
  23. data/lib/cloudkit/constants.rb +34 -0
  24. data/lib/cloudkit/exceptions.rb +10 -0
  25. data/lib/cloudkit/flash_session.rb +20 -0
  26. data/lib/cloudkit/oauth_filter.rb +266 -0
  27. data/lib/cloudkit/oauth_store.rb +48 -0
  28. data/lib/cloudkit/openid_filter.rb +236 -0
  29. data/lib/cloudkit/openid_store.rb +100 -0
  30. data/lib/cloudkit/rack/builder.rb +120 -0
  31. data/lib/cloudkit/rack/router.rb +20 -0
  32. data/lib/cloudkit/request.rb +177 -0
  33. data/lib/cloudkit/service.rb +162 -0
  34. data/lib/cloudkit/store.rb +349 -0
  35. data/lib/cloudkit/store/memory_table.rb +99 -0
  36. data/lib/cloudkit/store/resource.rb +269 -0
  37. data/lib/cloudkit/store/response.rb +52 -0
  38. data/lib/cloudkit/store/response_helpers.rb +84 -0
  39. data/lib/cloudkit/templates/authorize_request_token.erb +19 -0
  40. data/lib/cloudkit/templates/oauth_descriptor.erb +43 -0
  41. data/lib/cloudkit/templates/oauth_meta.erb +8 -0
  42. data/lib/cloudkit/templates/openid_login.erb +31 -0
  43. data/lib/cloudkit/templates/request_authorization.erb +23 -0
  44. data/lib/cloudkit/templates/request_token_denied.erb +18 -0
  45. data/lib/cloudkit/uri.rb +88 -0
  46. data/lib/cloudkit/user_store.rb +37 -0
  47. data/lib/cloudkit/util.rb +25 -0
  48. data/spec/ext_spec.rb +76 -0
  49. data/spec/flash_session_spec.rb +20 -0
  50. data/spec/memory_table_spec.rb +86 -0
  51. data/spec/oauth_filter_spec.rb +326 -0
  52. data/spec/oauth_store_spec.rb +10 -0
  53. data/spec/openid_filter_spec.rb +81 -0
  54. data/spec/openid_store_spec.rb +101 -0
  55. data/spec/rack_builder_spec.rb +39 -0
  56. data/spec/request_spec.rb +191 -0
  57. data/spec/resource_spec.rb +310 -0
  58. data/spec/service_spec.rb +1039 -0
  59. data/spec/spec_helper.rb +32 -0
  60. data/spec/store_spec.rb +10 -0
  61. data/spec/uri_spec.rb +93 -0
  62. data/spec/user_store_spec.rb +10 -0
  63. data/spec/util_spec.rb +11 -0
  64. metadata +180 -0
@@ -0,0 +1,1039 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ describe "A CloudKit::Service" do
4
+
5
+ it "should return a 501 for unimplemented methods" do
6
+ app = Rack::Builder.new {
7
+ use Rack::Lint
8
+ use CloudKit::Service, :collections => [:items, :things]
9
+ run echo_text('martino')
10
+ }
11
+
12
+ response = Rack::MockRequest.new(app).request('TRACE', '/items')
13
+ response.status.should == 501
14
+
15
+ # disable Rack::Lint so that an invalid HTTP method
16
+ # can be tested
17
+ app = Rack::Builder.new {
18
+ use CloudKit::Service, :collections => [:items, :things]
19
+ run echo_text('nothing')
20
+ }
21
+ response = Rack::MockRequest.new(app).request('REJUXTAPOSE', '/items')
22
+ response.status.should == 501
23
+ end
24
+
25
+ describe "using auth" do
26
+
27
+ before(:each) do
28
+ # mock an authenticated service in pieces
29
+ mock_auth = Proc.new { |env|
30
+ r = CloudKit::Request.new(env)
31
+ r.announce_auth(CLOUDKIT_OAUTH_FILTER_KEY)
32
+ }
33
+ inner_app = echo_text('martino')
34
+ service = CloudKit::Service.new(
35
+ inner_app, :collections => [:items, :things])
36
+ CloudKit.setup_storage_adapter unless CloudKit.storage_adapter
37
+ config = Rack::Config.new(service, &mock_auth)
38
+ authed_service = Rack::Lint.new(config)
39
+ @request = Rack::MockRequest.new(authed_service)
40
+ end
41
+
42
+ after(:each) do
43
+ CloudKit.storage_adapter.clear
44
+ end
45
+
46
+ it "should allow requests for / to pass through" do
47
+ response = @request.get('/')
48
+ response.body.should == 'martino'
49
+ end
50
+
51
+ it "should allow any non-specified resource request to pass through" do
52
+ response = @request.get('/hammers')
53
+ response.body.should == 'martino'
54
+ end
55
+
56
+ it "should return a 500 if authentication is configured incorrectly" do
57
+ # simulate auth requirement without CLOUDKIT_AUTH_KEY being set by the
58
+ # auth filter(s)
59
+ response = @request.get('/items')
60
+ response.status.should == 500
61
+ end
62
+
63
+ describe "on GET /cloudkit-meta" do
64
+
65
+ before(:each) do
66
+ @response = @request.get('/cloudkit-meta', VALID_TEST_AUTH)
67
+ end
68
+
69
+ it "should be successful" do
70
+ @response.status.should == 200
71
+ end
72
+
73
+ it "should return a list of hosted collection URIs" do
74
+ uris = JSON.parse(@response.body)['uris']
75
+ uris.sort.should == ['/items', '/things']
76
+ end
77
+
78
+ it "should return a Content-Type header" do
79
+ @response['Content-Type'].should == 'application/json'
80
+ end
81
+
82
+ it "should return an ETag" do
83
+ @response['ETag'].should_not be_nil
84
+ end
85
+
86
+ it "should not set a Last-Modified header" do
87
+ @response['Last-Modified'].should be_nil
88
+ end
89
+
90
+ end
91
+
92
+ describe "on GET /:collection" do
93
+
94
+ before(:each) do
95
+ 3.times do |i|
96
+ json = JSON.generate(:this => i.to_s)
97
+ @request.put("/items/#{i}", {:input => json}.merge(VALID_TEST_AUTH))
98
+ end
99
+ json = JSON.generate(:this => '4')
100
+ @request.put(
101
+ '/items/4', {:input => json}.merge(CLOUDKIT_AUTH_KEY => 'someoneelse'))
102
+ @response = @request.get(
103
+ '/items', {'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
104
+ @parsed_response = JSON.parse(@response.body)
105
+ end
106
+
107
+ it "should be successful" do
108
+ @response.status.should == 200
109
+ end
110
+
111
+ it "should return a list of URIs for all owner-originated resources" do
112
+ @parsed_response['uris'].sort.should == ['/items/0', '/items/1', '/items/2']
113
+ end
114
+
115
+ it "should sort descending on last_modified date" do
116
+ @parsed_response['uris'].should == ['/items/2', '/items/1', '/items/0']
117
+ end
118
+
119
+ it "should return the total number of uris" do
120
+ @parsed_response['total'].should_not be_nil
121
+ @parsed_response['total'].should == 3
122
+ end
123
+
124
+ it "should return the offset" do
125
+ @parsed_response['offset'].should_not be_nil
126
+ @parsed_response['offset'].should == 0
127
+ end
128
+
129
+ it "should return a Content-Type header" do
130
+ @response['Content-Type'].should == 'application/json'
131
+ end
132
+
133
+ it "should return an ETag" do
134
+ @response['ETag'].should_not be_nil
135
+ end
136
+
137
+ it "should return a Last-Modified date" do
138
+ @response['Last-Modified'].should_not be_nil
139
+ end
140
+
141
+ it "should accept a limit parameter" do
142
+ response = @request.get('/items?limit=2', VALID_TEST_AUTH)
143
+ parsed_response = JSON.parse(response.body)
144
+ parsed_response['uris'].should == ['/items/2', '/items/1']
145
+ parsed_response['total'].should == 3
146
+ end
147
+
148
+ it "should accept an offset parameter" do
149
+ response = @request.get('/items?offset=1', VALID_TEST_AUTH)
150
+ parsed_response = JSON.parse(response.body)
151
+ parsed_response['uris'].should == ['/items/1', '/items/0']
152
+ parsed_response['offset'].should == 1
153
+ parsed_response['total'].should == 3
154
+ end
155
+
156
+ it "should accept combined limit and offset parameters" do
157
+ response = @request.get('/items?limit=1&offset=1', VALID_TEST_AUTH)
158
+ parsed_response = JSON.parse(response.body)
159
+ parsed_response['uris'].should == ['/items/1']
160
+ parsed_response['offset'].should == 1
161
+ parsed_response['total'].should == 3
162
+ end
163
+
164
+ it "should return an empty list if no resources are found" do
165
+ response = @request.get('/things', VALID_TEST_AUTH)
166
+ parsed_response = JSON.parse(response.body)
167
+ parsed_response['uris'].should == []
168
+ parsed_response['total'].should == 0
169
+ parsed_response['offset'].should == 0
170
+ end
171
+
172
+ it "should return a resolved link header" do
173
+ @response['Link'].should_not be_nil
174
+ @response['Link'].match("<http://example.org/items/_resolved>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/resolved\"").should_not be_nil
175
+ end
176
+
177
+ end
178
+
179
+ describe "on GET /:collection/_resolved" do
180
+
181
+ before(:each) do
182
+ 3.times do |i|
183
+ json = JSON.generate(:this => i.to_s)
184
+ @request.put("/items/#{i}", {:input => json}.merge(VALID_TEST_AUTH))
185
+ end
186
+ json = JSON.generate(:this => '4')
187
+ @request.put(
188
+ '/items/4', {:input => json}.merge(CLOUDKIT_AUTH_KEY => 'someoneelse'))
189
+ @response = @request.get(
190
+ '/items/_resolved', {'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
191
+ @parsed_response = JSON.parse(@response.body)
192
+ end
193
+
194
+ it "should be successful" do
195
+ @response.status.should == 200
196
+ end
197
+
198
+ it "should return all owner-originated documents" do
199
+ @parsed_response['documents'].map{|d| d['uri']}.sort.should == ['/items/0', '/items/1', '/items/2']
200
+ end
201
+
202
+ it "should sort descending on last_modified date" do
203
+ @parsed_response['documents'].map{|d| d['uri']}.should == ['/items/2', '/items/1', '/items/0']
204
+ end
205
+
206
+ it "should return the total number of documents" do
207
+ @parsed_response['total'].should_not be_nil
208
+ @parsed_response['total'].should == 3
209
+ end
210
+
211
+ it "should return the offset" do
212
+ @parsed_response['offset'].should_not be_nil
213
+ @parsed_response['offset'].should == 0
214
+ end
215
+
216
+ it "should return a Content-Type header" do
217
+ @response['Content-Type'].should == 'application/json'
218
+ end
219
+
220
+ it "should return an ETag" do
221
+ @response['ETag'].should_not be_nil
222
+ end
223
+
224
+ it "should return a Last-Modified date" do
225
+ @response['Last-Modified'].should_not be_nil
226
+ end
227
+
228
+ it "should accept a limit parameter" do
229
+ response = @request.get('/items/_resolved?limit=2', VALID_TEST_AUTH)
230
+ parsed_response = JSON.parse(response.body)
231
+ parsed_response['documents'].map{|d| d['uri']}.should == ['/items/2', '/items/1']
232
+ parsed_response['total'].should == 3
233
+ end
234
+
235
+ it "should accept an offset parameter" do
236
+ response = @request.get('/items/_resolved?offset=1', VALID_TEST_AUTH)
237
+ parsed_response = JSON.parse(response.body)
238
+ parsed_response['documents'].map{|d| d['uri']}.should == ['/items/1', '/items/0']
239
+ parsed_response['offset'].should == 1
240
+ parsed_response['total'].should == 3
241
+ end
242
+
243
+ it "should accept combined limit and offset parameters" do
244
+ response = @request.get('/items/_resolved?limit=1&offset=1', VALID_TEST_AUTH)
245
+ parsed_response = JSON.parse(response.body)
246
+ parsed_response['documents'].map{|d| d['uri']}.should == ['/items/1']
247
+ parsed_response['offset'].should == 1
248
+ parsed_response['total'].should == 3
249
+ end
250
+
251
+ it "should return an empty list if no documents are found" do
252
+ response = @request.get('/things/_resolved', VALID_TEST_AUTH)
253
+ parsed_response = JSON.parse(response.body)
254
+ parsed_response['documents'].should == []
255
+ parsed_response['total'].should == 0
256
+ parsed_response['offset'].should == 0
257
+ end
258
+
259
+ it "should return an index link header" do
260
+ @response['Link'].should_not be_nil
261
+ @response['Link'].match("<http://example.org/items>; rel=\"index\"").should_not be_nil
262
+ end
263
+
264
+ end
265
+
266
+ describe "on GET /:collection/:id" do
267
+
268
+ before(:each) do
269
+ json = JSON.generate(:this => 'that')
270
+ @request.put('/items/abc', {:input => json}.merge(VALID_TEST_AUTH))
271
+ @response = @request.get(
272
+ '/items/abc', {'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
273
+ end
274
+
275
+ it "should be successful" do
276
+ @response.status.should == 200
277
+ end
278
+
279
+ it "should return a document for valid owner-originated requests" do
280
+ data = JSON.parse(@response.body)
281
+ data['this'].should == 'that'
282
+ end
283
+
284
+ it "should return a 404 if a document does not exist" do
285
+ response = @request.get('/items/nothing', VALID_TEST_AUTH)
286
+ response.status.should == 404
287
+ end
288
+
289
+ it "should return a Content-Type header" do
290
+ @response['Content-Type'].should == 'application/json'
291
+ end
292
+
293
+ it "should return an ETag header" do
294
+ @response['ETag'].should_not be_nil
295
+ end
296
+
297
+ it "should return a Last-Modified header" do
298
+ @response['Last-Modified'].should_not be_nil
299
+ end
300
+
301
+ it "should not return documents for unauthorized users" do
302
+ response = @request.get('/items/abc', CLOUDKIT_AUTH_KEY => 'bogus')
303
+ response.status.should == 404
304
+ end
305
+
306
+ it "should return a versions link header" do
307
+ @response['Link'].should_not be_nil
308
+ @response['Link'].match("<http://example.org/items/abc/versions>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/versions\"").should_not be_nil
309
+ end
310
+
311
+ end
312
+
313
+ describe "on GET /:collection/:id/versions" do
314
+
315
+ before(:each) do
316
+ @etags = []
317
+ 4.times do |i|
318
+ json = JSON.generate(:this => i)
319
+ options = {:input => json}.merge(VALID_TEST_AUTH)
320
+ options.filter_merge!('HTTP_IF_MATCH' => @etags.try(:last))
321
+ result = @request.put('/items/abc', options)
322
+ @etags << JSON.parse(result.body)['etag']
323
+ end
324
+ @response = @request.get(
325
+ '/items/abc/versions', {'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
326
+ @parsed_response = JSON.parse(@response.body)
327
+ end
328
+
329
+ it "should be successful" do
330
+ @response.status.should == 200
331
+ end
332
+
333
+ it "should be successful even if the current resource has been deleted" do
334
+ @request.delete('/items/abc', {'HTTP_IF_MATCH' => @etags.last}.merge(VALID_TEST_AUTH))
335
+ response = @request.get('/items/abc/versions', VALID_TEST_AUTH)
336
+ @response.status.should == 200
337
+ parsed_response = JSON.parse(response.body)
338
+ parsed_response['uris'].size.should == 4
339
+ end
340
+
341
+ it "should return a list of URIs for all versions of a resource" do
342
+ uris = @parsed_response['uris']
343
+ uris.should_not be_nil
344
+ uris.size.should == 4
345
+ end
346
+
347
+ it "should return a 404 if the resource does not exist" do
348
+ response = @request.get('/items/nothing/versions', VALID_TEST_AUTH)
349
+ response.status.should == 404
350
+ end
351
+
352
+ it "should return a 404 for non-owner-originated requests" do
353
+ response = @request.get(
354
+ '/items/abc/versions', CLOUDKIT_AUTH_KEY => 'someoneelse')
355
+ response.status.should == 404
356
+ end
357
+
358
+ it "should sort descending on last_modified date" do
359
+ @parsed_response['uris'].should == ['/items/abc'].concat(@etags[0..-2].reverse.map{|e| "/items/abc/versions/#{e}"})
360
+ end
361
+
362
+ it "should return the total number of uris" do
363
+ @parsed_response['total'].should_not be_nil
364
+ @parsed_response['total'].should == 4
365
+ end
366
+
367
+ it "should return the offset" do
368
+ @parsed_response['offset'].should_not be_nil
369
+ @parsed_response['offset'].should == 0
370
+ end
371
+
372
+ it "should return a Content-Type header" do
373
+ @response['Content-Type'].should == 'application/json'
374
+ end
375
+
376
+ it "should return an ETag" do
377
+ @response['ETag'].should_not be_nil
378
+ end
379
+
380
+ it "should return a Last-Modified date" do
381
+ @response['Last-Modified'].should_not be_nil
382
+ end
383
+
384
+ it "should accept a limit parameter" do
385
+ response = @request.get('/items/abc/versions?limit=2', VALID_TEST_AUTH)
386
+ parsed_response = JSON.parse(response.body)
387
+ parsed_response['uris'].should == ['/items/abc', "/items/abc/versions/#{@etags[-2]}"]
388
+ parsed_response['total'].should == 4
389
+ end
390
+
391
+ it "should accept an offset parameter" do
392
+ response = @request.get('/items/abc/versions?offset=1', VALID_TEST_AUTH)
393
+ parsed_response = JSON.parse(response.body)
394
+ parsed_response['uris'].should == @etags.reverse[1..-1].map{|e| "/items/abc/versions/#{e}"}
395
+ parsed_response['offset'].should == 1
396
+ parsed_response['total'].should == 4
397
+ end
398
+
399
+ it "should accept combined limit and offset parameters" do
400
+ response = @request.get('/items/abc/versions?limit=1&offset=1', VALID_TEST_AUTH)
401
+ parsed_response = JSON.parse(response.body)
402
+ parsed_response['uris'].should == ["/items/abc/versions/#{@etags[-2]}"]
403
+ parsed_response['offset'].should == 1
404
+ parsed_response['total'].should == 4
405
+ end
406
+
407
+ it "should return a resolved link header" do
408
+ @response['Link'].should_not be_nil
409
+ @response['Link'].match("<http://example.org/items/abc/versions/_resolved>; rel=\"http://joncrosby.me/cloudkit/1.0/rel/resolved\"").should_not be_nil
410
+ end
411
+
412
+ end
413
+
414
+ describe "on GET /:collections/:id/versions/_resolved" do
415
+
416
+ before(:each) do
417
+ @etags = []
418
+ 4.times do |i|
419
+ json = JSON.generate(:this => i)
420
+ options = {:input => json}.merge(VALID_TEST_AUTH)
421
+ options.filter_merge!('HTTP_IF_MATCH' => @etags.try(:last))
422
+ result = @request.put('/items/abc', options)
423
+ @etags << JSON.parse(result.body)['etag']
424
+ end
425
+ @response = @request.get(
426
+ '/items/abc/versions/_resolved', {'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
427
+ @parsed_response = JSON.parse(@response.body)
428
+ end
429
+
430
+ it "should be successful" do
431
+ @response.status.should == 200
432
+ end
433
+
434
+ it "should be successful even if the current resource has been deleted" do
435
+ @request.delete(
436
+ '/items/abc', {'HTTP_IF_MATCH' => @etags.last}.merge(VALID_TEST_AUTH))
437
+ response = @request.get('/items/abc/versions/_resolved', VALID_TEST_AUTH)
438
+ @response.status.should == 200
439
+ parsed_response = JSON.parse(response.body)
440
+ parsed_response['documents'].size.should == 4
441
+ end
442
+
443
+ it "should return all versions of a document" do
444
+ documents = @parsed_response['documents']
445
+ documents.should_not be_nil
446
+ documents.size.should == 4
447
+ end
448
+
449
+ it "should return a 404 if the resource does not exist" do
450
+ response = @request.get('/items/nothing/versions/_resolved', VALID_TEST_AUTH)
451
+ response.status.should == 404
452
+ end
453
+
454
+ it "should return a 404 for non-owner-originated requests" do
455
+ response = @request.get('/items/abc/versions/_resolved', CLOUDKIT_AUTH_KEY => 'someoneelse')
456
+ response.status.should == 404
457
+ end
458
+
459
+ it "should sort descending on last_modified date" do
460
+ @parsed_response['documents'].map{|d| d['uri']}.should == ['/items/abc'].concat(@etags[0..-2].reverse.map{|e| "/items/abc/versions/#{e}"})
461
+ end
462
+
463
+ it "should return the total number of documents" do
464
+ @parsed_response['total'].should_not be_nil
465
+ @parsed_response['total'].should == 4
466
+ end
467
+
468
+ it "should return the offset" do
469
+ @parsed_response['offset'].should_not be_nil
470
+ @parsed_response['offset'].should == 0
471
+ end
472
+
473
+ it "should return a Content-Type header" do
474
+ @response['Content-Type'].should == 'application/json'
475
+ end
476
+
477
+ it "should return an ETag" do
478
+ @response['ETag'].should_not be_nil
479
+ end
480
+
481
+ it "should return a Last-Modified date" do
482
+ @response['Last-Modified'].should_not be_nil
483
+ end
484
+
485
+ it "should accept a limit parameter" do
486
+ response = @request.get(
487
+ '/items/abc/versions/_resolved?limit=2', VALID_TEST_AUTH)
488
+ parsed_response = JSON.parse(response.body)
489
+ parsed_response['documents'].map{|d| d['uri']}.should == ['/items/abc', "/items/abc/versions/#{@etags[-2]}"]
490
+ parsed_response['total'].should == 4
491
+ end
492
+
493
+ it "should accept an offset parameter" do
494
+ response = @request.get(
495
+ '/items/abc/versions/_resolved?offset=1', VALID_TEST_AUTH)
496
+ parsed_response = JSON.parse(response.body)
497
+ parsed_response['documents'].map{|d| d['uri']}.should == @etags.reverse[1..-1].map{|e| "/items/abc/versions/#{e}"}
498
+ parsed_response['offset'].should == 1
499
+ parsed_response['total'].should == 4
500
+ end
501
+
502
+ it "should accept combined limit and offset parameters" do
503
+ response = @request.get(
504
+ '/items/abc/versions/_resolved?limit=1&offset=1', VALID_TEST_AUTH)
505
+ parsed_response = JSON.parse(response.body)
506
+ parsed_response['documents'].map{|d| d['uri']}.should == ["/items/abc/versions/#{@etags[-2]}"]
507
+ parsed_response['offset'].should == 1
508
+ parsed_response['total'].should == 4
509
+ end
510
+
511
+ it "should return an index link header" do
512
+ @response['Link'].should_not be_nil
513
+ @response['Link'].match("<http://example.org/items/abc/versions>; rel=\"index\"").should_not be_nil
514
+ end
515
+
516
+ end
517
+
518
+ describe "on GET /:collection/:id/versions/:etag" do
519
+
520
+ before(:each) do
521
+ @etags = []
522
+ 2.times do |i|
523
+ json = JSON.generate(:this => i)
524
+ options = {:input => json}.merge(VALID_TEST_AUTH)
525
+ options.filter_merge!('HTTP_IF_MATCH' => @etags.try(:last))
526
+ result = @request.put('/items/abc', options)
527
+ @etags << JSON.parse(result.body)['etag']
528
+ end
529
+ @response = @request.get(
530
+ "/items/abc/versions/#{@etags.first}", VALID_TEST_AUTH)
531
+ @parsed_response = JSON.parse(@response.body)
532
+ end
533
+
534
+ it "should be successful" do
535
+ @response.status.should == 200
536
+ end
537
+
538
+ it "should return a document for valid owner-originated requests" do
539
+ @parsed_response['this'].should == 0
540
+ end
541
+
542
+ it "should return a 404 if a document is not found" do
543
+ response = @request.get(
544
+ "/items/nothing/versions/#{@etags.first}", VALID_TEST_AUTH)
545
+ response.status.should == 404
546
+ end
547
+
548
+ it "should return a Content-Type header" do
549
+ @response['Content-Type'].should == 'application/json'
550
+ end
551
+
552
+ it "should return an ETag header" do
553
+ @response['ETag'].should_not be_nil
554
+ end
555
+
556
+ it "should return a Last-Modified header" do
557
+ @response['Last-Modified'].should_not be_nil
558
+ end
559
+
560
+ it "should not return documents for unauthorized users" do
561
+ response = @request.get(
562
+ "/items/abc/versions/#{@etags.first}", CLOUDKIT_AUTH_KEY => 'someoneelse')
563
+ response.status.should == 404
564
+ end
565
+
566
+ end
567
+
568
+ describe "on POST /:collection" do
569
+
570
+ before(:each) do
571
+ json = JSON.generate(:this => 'that')
572
+ @response = @request.post(
573
+ '/items', {:input => json, 'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
574
+ @body = JSON.parse(@response.body)
575
+ end
576
+
577
+ it "should store the document" do
578
+ result = @request.get(@body['uri'], VALID_TEST_AUTH)
579
+ result.status.should == 200
580
+ end
581
+
582
+ it "should return a 201 when successful" do
583
+ @response.status.should == 201
584
+ end
585
+
586
+ it "should return the metadata" do
587
+ @body.keys.size.should == 4
588
+ @body.keys.sort.should == ['etag', 'last_modified', 'ok', 'uri']
589
+ end
590
+
591
+ it "should set the Content-Type header" do
592
+ @response['Content-Type'].should == 'application/json'
593
+ end
594
+
595
+ it "should not set an ETag header" do
596
+ @response['ETag'].should be_nil
597
+ end
598
+
599
+ it "should not set a Last-Modified header" do
600
+ @response['Last-Modified'].should be_nil
601
+ end
602
+
603
+ it "should return a Location header" do
604
+ @response['Location'].should match(/http:\/\/example.org#{@body['uri']}/)
605
+ end
606
+
607
+ it "should return a 422 if parsing fails" do
608
+ response = @request.post('/items', {:input => 'fail'}.merge(VALID_TEST_AUTH))
609
+ response.status.should == 422
610
+ end
611
+
612
+ end
613
+
614
+ describe "on PUT /:collection/:id" do
615
+
616
+ before(:each) do
617
+ json = JSON.generate(:this => 'that')
618
+ @original = @request.put(
619
+ '/items/abc', {:input => json}.merge(VALID_TEST_AUTH))
620
+ etag = JSON.parse(@original.body)['etag']
621
+ json = JSON.generate(:this => 'other')
622
+ @response = @request.put(
623
+ '/items/abc',
624
+ :input => json,
625
+ 'HTTP_IF_MATCH' => etag,
626
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
627
+ @json = JSON.parse(@response.body)
628
+ end
629
+
630
+ it "should create a document if it does not already exist" do
631
+ json = JSON.generate(:this => 'thing')
632
+ response = @request.put(
633
+ '/items/xyz', {
634
+ :input => json,
635
+ 'HTTP_HOST' => 'example.org'}.merge(VALID_TEST_AUTH))
636
+ response.status.should == 201
637
+ result = @request.get('/items/xyz', VALID_TEST_AUTH)
638
+ result.status.should == 200
639
+ parsed_json = JSON.parse(result.body)['this']
640
+ parsed_json.should == 'thing'
641
+ response['Location'].should match(/http:\/\/example.org#{parsed_json['uri']}/)
642
+ end
643
+
644
+ it "should not create new resources using deleted resource URIs" do
645
+ # This situation occurs when a stale client attempts to update
646
+ # a resource that has been removed. This test verifies that CloudKit
647
+ # does not attempt to create a new item with a URI equal to the
648
+ # removed item.
649
+ etag = JSON.parse(@response.body)['etag'];
650
+ @request.delete(
651
+ '/items/abc',
652
+ 'HTTP_IF_MATCH' => etag,
653
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
654
+ json = JSON.generate(:foo => 'bar')
655
+ response = @request.put(
656
+ '/items/abc',
657
+ :input => json,
658
+ 'HTTP_IF_MATCH' => etag,
659
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
660
+ response.status.should == 410
661
+ end
662
+
663
+ it "should update the document if it already exists" do
664
+ @response.status.should == 200
665
+ result = @request.get('/items/abc', VALID_TEST_AUTH)
666
+ JSON.parse(result.body)['this'].should == 'other'
667
+ end
668
+
669
+ it "should return the metadata" do
670
+ @json.keys.size.should == 4
671
+ @json.keys.sort.should == ['etag', 'last_modified', 'ok', 'uri']
672
+ end
673
+
674
+ it "should set the Content-Type header" do
675
+ @response['Content-Type'].should == 'application/json'
676
+ end
677
+
678
+ it "should not set an ETag header" do
679
+ @response['ETag'].should be_nil
680
+ end
681
+
682
+ it "should not set a Last-Modified header" do
683
+ @response['Last-Modified'].should be_nil
684
+ end
685
+
686
+ it "should not allow a remote_user change" do
687
+ json = JSON.generate(:this => 'other')
688
+ response = @request.put(
689
+ '/items/abc',
690
+ :input => json,
691
+ 'HTTP_IF_MATCH' => @json['etag'],
692
+ CLOUDKIT_AUTH_KEY => 'someone_else')
693
+ response.status.should == 404
694
+ end
695
+
696
+ it "should detect and return conflicts" do
697
+ client_a_input = JSON.generate(:this => 'updated')
698
+ client_b_input = JSON.generate(:other => 'thing')
699
+ response = @request.put(
700
+ '/items/abc',
701
+ :input => client_a_input,
702
+ 'HTTP_IF_MATCH' => @json['etag'],
703
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
704
+ response.status.should == 200
705
+ response = @request.put(
706
+ '/items/abc',
707
+ :input => client_b_input,
708
+ 'HTTP_IF_MATCH' => @json['etag'],
709
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
710
+ response.status.should == 412
711
+ end
712
+
713
+ it "should require an ETag for updates" do
714
+ json = JSON.generate(:this => 'updated')
715
+ response = @request.put(
716
+ '/items/abc',
717
+ {:input => json}.merge(VALID_TEST_AUTH))
718
+ response.status.should == 400
719
+ end
720
+
721
+ it "should return a 422 if parsing fails" do
722
+ response = @request.put(
723
+ '/items/zzz', {:input => 'fail'}.merge(VALID_TEST_AUTH))
724
+ response.status.should == 422
725
+ end
726
+
727
+ it "should version document updates" do
728
+ json = JSON.generate(:this => 'updated')
729
+ response = @request.put(
730
+ '/items/abc',
731
+ :input => json,
732
+ 'HTTP_IF_MATCH' => @json['etag'],
733
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
734
+ response.status.should == 200
735
+ etag = JSON.parse(response.body)['etag']
736
+ json = JSON.generate(:this => 'updated again')
737
+ new_response = @request.put(
738
+ '/items/abc',
739
+ :input => json,
740
+ 'HTTP_IF_MATCH' => etag,
741
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
742
+ new_response.status.should == 200
743
+ new_etag = JSON.parse(new_response.body)['etag']
744
+ new_etag.should_not == etag
745
+ end
746
+
747
+ describe "using POST method tunneling" do
748
+
749
+ it "should behave like a PUT" do
750
+ json = JSON.generate(:this => 'thing')
751
+ response = @request.post(
752
+ '/items/xyz?_method=PUT',
753
+ {:input => json}.merge(VALID_TEST_AUTH))
754
+ response.status.should == 201
755
+ result = @request.get('/items/xyz', VALID_TEST_AUTH)
756
+ result.status.should == 200
757
+ JSON.parse(result.body)['this'].should == 'thing'
758
+ end
759
+
760
+ end
761
+
762
+ end
763
+
764
+ describe "on DELETE /:collection/:id" do
765
+
766
+ before(:each) do
767
+ json = JSON.generate(:this => 'that')
768
+ @result = @request.put(
769
+ '/items/abc', {:input => json}.merge(VALID_TEST_AUTH))
770
+ @etag = JSON.parse(@result.body)['etag']
771
+ end
772
+
773
+ it "should delete the document" do
774
+ response = @request.delete(
775
+ '/items/abc',
776
+ 'HTTP_IF_MATCH' => @etag,
777
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
778
+ response.status.should == 200
779
+ result = @request.get('/items/abc', VALID_TEST_AUTH)
780
+ result.status.should == 410
781
+ end
782
+
783
+ it "should return the metadata" do
784
+ response = @request.delete(
785
+ '/items/abc',
786
+ 'HTTP_IF_MATCH' => @etag,
787
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
788
+ json = JSON.parse(response.body)
789
+ json.keys.size.should == 4
790
+ json.keys.sort.should == ['etag', 'last_modified', 'ok', 'uri']
791
+ end
792
+
793
+ it "should use the archived URI in the JSON result" do
794
+ response = @request.delete(
795
+ '/items/abc',
796
+ 'HTTP_IF_MATCH' => @etag,
797
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
798
+ json = JSON.parse(response.body)
799
+ json['uri'].should match(/\/items\/abc\/versions\/.+/)
800
+ end
801
+
802
+ it "should not change the ETag for the archived version" do
803
+ response = @request.delete(
804
+ '/items/abc',
805
+ 'HTTP_IF_MATCH' => @etag,
806
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
807
+ json = JSON.parse(response.body)
808
+ json['etag'].should == @etag
809
+ end
810
+
811
+ it "should set the Content-Type header" do
812
+ response = @request.delete(
813
+ '/items/abc',
814
+ 'HTTP_IF_MATCH' => @etag,
815
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
816
+ response['Content-Type'].should == 'application/json'
817
+ end
818
+
819
+ it "should not set an ETag header" do
820
+ response = @request.delete(
821
+ '/items/abc',
822
+ 'HTTP_IF_MATCH' => @etag,
823
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
824
+ response['ETag'].should be_nil
825
+ end
826
+
827
+ it "should not set a Last-Modified header" do
828
+ response = @request.delete(
829
+ '/items/abc',
830
+ 'HTTP_IF_MATCH' => @etag,
831
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
832
+ response['Last-Modified'].should be_nil
833
+ end
834
+
835
+ it "should return a 404 for items that have never existed" do
836
+ response = @request.delete(
837
+ '/items/zzz',
838
+ 'HTTP_IF_MATCH' => @etag,
839
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
840
+ response.status.should == 404
841
+ end
842
+
843
+ it "should require an ETag" do
844
+ response = @request.delete(
845
+ '/items/abc',
846
+ VALID_TEST_AUTH)
847
+ response.status.should == 400
848
+ end
849
+
850
+ it "should verify the user in the doc" do
851
+ response = @request.delete(
852
+ '/items/abc',
853
+ 'HTTP_IF_MATCH' => @etag,
854
+ CLOUDKIT_AUTH_KEY => 'someoneelse')
855
+ response.status.should == 404
856
+ end
857
+
858
+ it "should detect and return conflicts" do
859
+ json = JSON.generate(:this => 'that')
860
+ result = @request.put(
861
+ '/items/123', {:input => json}.merge(VALID_TEST_AUTH))
862
+ etag = JSON.parse(result.body)['etag']
863
+ client_a_input = JSON.generate(:this => 'updated')
864
+ client_b_input = JSON.generate(:other => 'thing')
865
+ response = @request.put(
866
+ '/items/123',
867
+ :input => client_a_input,
868
+ 'HTTP_IF_MATCH' => etag,
869
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
870
+ response.status.should == 200
871
+ response = @request.delete(
872
+ '/items/123',
873
+ :input => client_b_input,
874
+ 'HTTP_IF_MATCH' => etag,
875
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
876
+ response.status.should == 412
877
+ end
878
+
879
+ it "should retain version history" do
880
+ response = @request.delete(
881
+ '/items/abc',
882
+ 'HTTP_IF_MATCH' => @etag,
883
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
884
+ response.status.should == 200
885
+ response = @request.get(
886
+ '/items/abc/versions',
887
+ VALID_TEST_AUTH)
888
+ json = JSON.parse(response.body)
889
+ json['total'].should == 1
890
+ end
891
+
892
+ describe "using POST method tunneling" do
893
+
894
+ it "should behave like a DELETE" do
895
+ response = @request.post(
896
+ '/items/abc?_method=DELETE',
897
+ 'HTTP_IF_MATCH' => @etag,
898
+ CLOUDKIT_AUTH_KEY => TEST_REMOTE_USER)
899
+ response.status.should == 200
900
+ result = @request.get('/items/abc', VALID_TEST_AUTH)
901
+ result.status.should == 410
902
+ end
903
+
904
+ end
905
+
906
+ end
907
+
908
+ describe "on OPTIONS /:collection" do
909
+
910
+ before(:each) do
911
+ @response = @request.request('OPTIONS', '/items', VALID_TEST_AUTH)
912
+ end
913
+
914
+ it "should return a 200 status" do
915
+ @response.status.should == 200
916
+ end
917
+
918
+ it "should return a list of available methods" do
919
+ @response['Allow'].should_not be_nil
920
+ methods = @response['Allow'].split(', ')
921
+ methods.sort.should == ['GET', 'HEAD', 'OPTIONS', 'POST']
922
+ end
923
+
924
+ describe "using POST method tunneling" do
925
+
926
+ it "should behave like an OPTIONS request" do
927
+ response = @request.post('/items?_method=OPTIONS', VALID_TEST_AUTH)
928
+ response['Allow'].should_not be_nil
929
+ methods = response['Allow'].split(', ')
930
+ methods.sort.should == ['GET', 'HEAD', 'OPTIONS', 'POST']
931
+ end
932
+ end
933
+
934
+ end
935
+
936
+ describe "on OPTIONS /:collection/_resolved" do
937
+
938
+ before(:each) do
939
+ @response = @request.request('OPTIONS', '/items/_resolved', VALID_TEST_AUTH)
940
+ end
941
+
942
+ it "should return a 200 status" do
943
+ @response.status.should == 200
944
+ end
945
+
946
+ it "should return a list of available methods" do
947
+ @response['Allow'].should_not be_nil
948
+ methods = @response['Allow'].split(', ')
949
+ methods.sort.should == ['GET', 'HEAD', 'OPTIONS']
950
+ end
951
+
952
+ end
953
+
954
+ describe "on OPTIONS /:collection/:id" do
955
+
956
+ before(:each) do
957
+ @response = @request.request('OPTIONS', '/items/xyz', VALID_TEST_AUTH)
958
+ end
959
+
960
+ it "should return a 200 status" do
961
+ @response.status.should == 200
962
+ end
963
+
964
+ it "should return a list of available methods" do
965
+ @response['Allow'].should_not be_nil
966
+ methods = @response['Allow'].split(', ')
967
+ methods.sort.should == ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT']
968
+ end
969
+
970
+ end
971
+
972
+ describe "on OPTIONS /:collection/:id/versions" do
973
+
974
+ before(:each) do
975
+ @response = @request.request('OPTIONS', '/items/xyz/versions', VALID_TEST_AUTH)
976
+ end
977
+
978
+ it "should return a 200 status" do
979
+ @response.status.should == 200
980
+ end
981
+
982
+ it "should return a list of available methods" do
983
+ @response['Allow'].should_not be_nil
984
+ methods = @response['Allow'].split(', ')
985
+ methods.sort.should == ['GET', 'HEAD', 'OPTIONS']
986
+ end
987
+
988
+ end
989
+
990
+ describe "on OPTIONS /:collection/:id/versions/_resolved" do
991
+
992
+ before(:each) do
993
+ @response = @request.request('OPTIONS', '/items/xyz/versions/_resolved', VALID_TEST_AUTH)
994
+ end
995
+
996
+ it "should return a 200 status" do
997
+ @response.status.should == 200
998
+ end
999
+
1000
+ it "should return a list of available methods" do
1001
+ @response['Allow'].should_not be_nil
1002
+ methods = @response['Allow'].split(', ')
1003
+ methods.sort.should == ['GET', 'HEAD', 'OPTIONS']
1004
+ end
1005
+
1006
+ end
1007
+
1008
+ describe "on OPTIONS /:collection/:id/versions/:etag" do
1009
+
1010
+ before(:each) do
1011
+ @response = @request.request('OPTIONS', "/items/xyz/versions/abc", VALID_TEST_AUTH)
1012
+ end
1013
+
1014
+ it "should return a 200 status" do
1015
+ @response.status.should == 200
1016
+ end
1017
+
1018
+ it "should return a list of available methods" do
1019
+ @response['Allow'].should_not be_nil
1020
+ methods = @response['Allow'].split(', ')
1021
+ methods.sort.should == ['GET', 'HEAD', 'OPTIONS']
1022
+ end
1023
+
1024
+ end
1025
+
1026
+ describe "on HEAD" do
1027
+
1028
+ it "should return an empty body" do
1029
+ json = JSON.generate(:this => 'that')
1030
+ @request.put('/items/abc', {:input => json}.merge(VALID_TEST_AUTH))
1031
+ response = @request.request('HEAD', '/items/abc', VALID_TEST_AUTH)
1032
+ response.body.should == ''
1033
+ response = @request.request('HEAD', '/items', VALID_TEST_AUTH)
1034
+ response.body.should == ''
1035
+ end
1036
+
1037
+ end
1038
+ end
1039
+ end