2755: Add api server config to enable mandatory Keep signatures.
[arvados.git] / services / api / test / functional / arvados / v1 / collections_controller_test.rb
1 require 'test_helper'
2
3 class Arvados::V1::CollectionsControllerTest < ActionController::TestCase
4
5   setup do
6     # Unless otherwise specified in the test, we want normal/secure behavior.
7     permit_unsigned_manifests false
8   end
9
10   teardown do
11     # Reset to secure behavior after each test.
12     permit_unsigned_manifests false
13   end
14
15   def permit_unsigned_manifests isok=true
16     # Set security model for the life of a test.
17     Rails.configuration.permit_create_collection_with_unsigned_manifest = isok
18   end
19
20   test "should get index" do
21     authorize_with :active
22     get :index
23     assert_response :success
24     assert_not_nil assigns(:objects)
25   end
26
27   [0,1,2].each do |limit|
28     test "get index with limit=#{limit}" do
29       authorize_with :active
30       get :index, limit: limit
31       assert_response :success
32       assert_equal limit, assigns(:objects).count
33       resp = JSON.parse(@response.body)
34       assert_equal limit, resp['limit']
35     end
36   end
37
38   test "items.count == items_available" do
39     authorize_with :active
40     get :index, limit: 100000
41     assert_response :success
42     resp = JSON.parse(@response.body)
43     assert_equal resp['items_available'], assigns(:objects).length
44     assert_equal resp['items_available'], resp['items'].count
45     unique_uuids = resp['items'].collect { |i| i['uuid'] }.compact.uniq
46     assert_equal unique_uuids.count, resp['items'].count
47   end
48
49   test "get index with limit=2 offset=99999" do
50     # Assume there are not that many test fixtures.
51     authorize_with :active
52     get :index, limit: 2, offset: 99999
53     assert_response :success
54     assert_equal 0, assigns(:objects).count
55     resp = JSON.parse(@response.body)
56     assert_equal 2, resp['limit']
57     assert_equal 99999, resp['offset']
58   end
59
60   test "create with unsigned manifest" do
61     permit_unsigned_manifests
62     authorize_with :active
63     test_collection = {
64       manifest_text: <<-EOS
65 . d41d8cd98f00b204e9800998ecf8427e+0 0:0:foo.txt
66 . acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:bar.txt
67 . acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:bar.txt
68 ./baz acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:bar.txt
69 EOS
70     }
71     test_collection[:uuid] =
72       Digest::MD5.hexdigest(test_collection[:manifest_text]) +
73       '+' +
74       test_collection[:manifest_text].length.to_s
75
76     # post :create will modify test_collection in place, so we save a copy first.
77     # Hash.deep_dup is not sufficient as it preserves references of strings (??!?)
78     post_collection = Marshal.load(Marshal.dump(test_collection))
79     post :create, {
80       collection: post_collection
81     }
82
83     assert_response :success
84     assert_nil assigns(:objects)
85
86     get :show, {
87       id: test_collection[:uuid]
88     }
89     assert_response :success
90     assert_not_nil assigns(:object)
91     resp = JSON.parse(@response.body)
92     assert_equal test_collection[:uuid], resp['uuid']
93
94     # The manifest in the response will have had permission hints added.
95     # Remove any permission hints in the response before comparing it to the source.
96     stripped_manifest = resp['manifest_text'].gsub(/\+A[A-Za-z0-9@_-]+/, '')
97     assert_equal test_collection[:manifest_text], stripped_manifest
98     assert_equal 9, resp['data_size']
99     assert_equal [['.', 'foo.txt', 0],
100                   ['.', 'bar.txt', 6],
101                   ['./baz', 'bar.txt', 3]], resp['files']
102   end
103
104   test "list of files is correct for empty manifest" do
105     authorize_with :active
106     test_collection = {
107       manifest_text: "",
108       uuid: "d41d8cd98f00b204e9800998ecf8427e+0"
109     }
110     post :create, {
111       collection: test_collection
112     }
113     assert_response :success
114
115     get :show, {
116       id: "d41d8cd98f00b204e9800998ecf8427e+0"
117     }
118     assert_response :success
119     resp = JSON.parse(@response.body)
120     assert_equal [], resp['files']
121   end
122
123   test "create with owner_uuid set to owned group" do
124     permit_unsigned_manifests
125     authorize_with :active
126     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
127     post :create, {
128       collection: {
129         owner_uuid: 'zzzzz-j7d0g-rew6elm53kancon',
130         manifest_text: manifest_text,
131         uuid: "d30fe8ae534397864cb96c544f4cf102"
132       }
133     }
134     assert_response :success
135     resp = JSON.parse(@response.body)
136     assert_equal 'zzzzz-tpzed-000000000000000', resp['owner_uuid']
137   end
138
139   test "create with owner_uuid set to group i can_manage" do
140     permit_unsigned_manifests
141     authorize_with :active
142     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
143     post :create, {
144       collection: {
145         owner_uuid: 'zzzzz-j7d0g-8ulrifv67tve5sx',
146         manifest_text: manifest_text,
147         uuid: "d30fe8ae534397864cb96c544f4cf102"
148       }
149     }
150     assert_response :success
151     resp = JSON.parse(@response.body)
152     assert_equal 'zzzzz-tpzed-000000000000000', resp['owner_uuid']
153   end
154
155   test "create with owner_uuid set to group with no can_manage permission" do
156     permit_unsigned_manifests
157     authorize_with :active
158     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
159     post :create, {
160       collection: {
161         owner_uuid: 'zzzzz-j7d0g-it30l961gq3t0oi',
162         manifest_text: manifest_text,
163         uuid: "d30fe8ae534397864cb96c544f4cf102"
164       }
165     }
166     assert_response 403
167   end
168
169   test "admin create with owner_uuid set to group with no permission" do
170     permit_unsigned_manifests
171     authorize_with :admin
172     manifest_text = ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n"
173     post :create, {
174       collection: {
175         owner_uuid: 'zzzzz-j7d0g-it30l961gq3t0oi',
176         manifest_text: manifest_text,
177         uuid: "d30fe8ae534397864cb96c544f4cf102"
178       }
179     }
180     assert_response :success
181   end
182
183   test "should create with collection passed as json" do
184     permit_unsigned_manifests
185     authorize_with :active
186     post :create, {
187       collection: <<-EOS
188       {
189         "manifest_text":". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n",\
190         "uuid":"d30fe8ae534397864cb96c544f4cf102"\
191       }
192       EOS
193     }
194     assert_response :success
195   end
196
197   test "should fail to create with checksum mismatch" do
198     permit_unsigned_manifests
199     authorize_with :active
200     post :create, {
201       collection: <<-EOS
202       {
203         "manifest_text":". d41d8cd98f00b204e9800998ecf8427e 0:0:bar.txt\n",\
204         "uuid":"d30fe8ae534397864cb96c544f4cf102"\
205       }
206       EOS
207     }
208     assert_response 422
209   end
210
211   test "collection UUID is normalized when created" do
212     permit_unsigned_manifests
213     authorize_with :active
214     post :create, {
215       collection: {
216         manifest_text: ". d41d8cd98f00b204e9800998ecf8427e 0:0:foo.txt\n",
217         uuid: "d30fe8ae534397864cb96c544f4cf102+47+Khint+Xhint+Zhint"
218       }
219     }
220     assert_response :success
221     assert_not_nil assigns(:object)
222     resp = JSON.parse(@response.body)
223     assert_equal "d30fe8ae534397864cb96c544f4cf102+47", resp['uuid']
224   end
225
226   test "get full provenance for baz file" do
227     authorize_with :active
228     get :provenance, id: 'ea10d51bcf88862dbcc36eb292017dfd+45'
229     assert_response :success
230     resp = JSON.parse(@response.body)
231     assert_not_nil resp['ea10d51bcf88862dbcc36eb292017dfd+45'] # baz
232     assert_not_nil resp['fa7aeb5140e2848d39b416daeef4ffc5+45'] # bar
233     assert_not_nil resp['1f4b0bc7583c2a7f9102c395f4ffc5e3+45'] # foo
234     assert_not_nil resp['zzzzz-8i9sb-cjs4pklxxjykyuq'] # bar->baz
235     assert_not_nil resp['zzzzz-8i9sb-aceg2bnq7jt7kon'] # foo->bar
236   end
237
238   test "get no provenance for foo file" do
239     # spectator user cannot even see baz collection
240     authorize_with :spectator
241     get :provenance, id: '1f4b0bc7583c2a7f9102c395f4ffc5e3+45'
242     assert_response 404
243   end
244
245   test "get partial provenance for baz file" do
246     # spectator user can see bar->baz job, but not foo->bar job
247     authorize_with :spectator
248     get :provenance, id: 'ea10d51bcf88862dbcc36eb292017dfd+45'
249     assert_response :success
250     resp = JSON.parse(@response.body)
251     assert_not_nil resp['ea10d51bcf88862dbcc36eb292017dfd+45'] # baz
252     assert_not_nil resp['fa7aeb5140e2848d39b416daeef4ffc5+45'] # bar
253     assert_not_nil resp['zzzzz-8i9sb-cjs4pklxxjykyuq']     # bar->baz
254     assert_nil resp['zzzzz-8i9sb-aceg2bnq7jt7kon']         # foo->bar
255     assert_nil resp['1f4b0bc7583c2a7f9102c395f4ffc5e3+45'] # foo
256   end
257
258   test "search collections with 'any' operator" do
259     authorize_with :active
260     get :index, {
261       where: { any: ['contains', '7f9102c395f4ffc5e3'] }
262     }
263     assert_response :success
264     found = assigns(:objects).collect(&:uuid)
265     assert_equal 1, found.count
266     assert_equal true, !!found.index('1f4b0bc7583c2a7f9102c395f4ffc5e3+45')
267   end
268
269   [false, true].each do |permit_unsigned|
270     test "create collection with signed manifest, permit_unsigned=#{permit_unsigned}" do
271       permit_unsigned_manifests permit_unsigned
272       authorize_with :active
273       locators = %w(
274       d41d8cd98f00b204e9800998ecf8427e+0
275       acbd18db4cc2f85cedef654fccc4a4d8+3
276       ea10d51bcf88862dbcc36eb292017dfd+45)
277
278       unsigned_manifest = locators.map { |loc|
279         ". " + loc + " 0:0:foo.txt\n"
280       }.join()
281       manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest) +
282         '+' +
283         unsigned_manifest.length.to_s
284
285       # Build a manifest with both signed and unsigned locators.
286       signing_opts = {
287         key: Rails.configuration.blob_signing_key,
288         api_token: api_token(:active),
289       }
290       signed_locators = locators.collect do |x|
291         Blob.sign_locator x, signing_opts
292       end
293       if permit_unsigned
294         # Leave a non-empty blob unsigned.
295         signed_locators[1] = locators[1]
296       else
297         # Leave the empty blob unsigned. This should still be allowed.
298         signed_locators[0] = locators[0]
299       end
300       signed_manifest =
301         ". " + signed_locators[0] + " 0:0:foo.txt\n" +
302         ". " + signed_locators[1] + " 0:0:foo.txt\n" +
303         ". " + signed_locators[2] + " 0:0:foo.txt\n"
304
305       post :create, {
306         collection: {
307           manifest_text: signed_manifest,
308           uuid: manifest_uuid,
309         }
310       }
311       assert_response :success
312       assert_not_nil assigns(:object)
313       resp = JSON.parse(@response.body)
314       assert_equal manifest_uuid, resp['uuid']
315       assert_equal 48, resp['data_size']
316       # All of the locators in the output must be signed.
317       resp['manifest_text'].lines.each do |entry|
318         m = /([[:xdigit:]]{32}\+\S+)/.match(entry)
319         if m
320           assert Blob.verify_signature m[0], signing_opts
321         end
322       end
323     end
324   end
325
326   test "create collection with signed manifest and explicit TTL" do
327     authorize_with :active
328     locators = %w(
329       d41d8cd98f00b204e9800998ecf8427e+0
330       acbd18db4cc2f85cedef654fccc4a4d8+3
331       ea10d51bcf88862dbcc36eb292017dfd+45)
332
333     unsigned_manifest = locators.map { |loc|
334       ". " + loc + " 0:0:foo.txt\n"
335     }.join()
336     manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest) +
337       '+' +
338       unsigned_manifest.length.to_s
339
340     # build a manifest with both signed and unsigned locators.
341     # TODO(twp): in phase 4, all locators will need to be signed, so
342     # this test should break and will need to be rewritten. Issue #2755.
343     signing_opts = {
344       key: Rails.configuration.blob_signing_key,
345       api_token: api_token(:active),
346       ttl: 3600   # 1 hour
347     }
348     signed_manifest =
349       ". " + locators[0] + " 0:0:foo.txt\n" +
350       ". " + Blob.sign_locator(locators[1], signing_opts) + " 0:0:foo.txt\n" +
351       ". " + Blob.sign_locator(locators[2], signing_opts) + " 0:0:foo.txt\n"
352
353     post :create, {
354       collection: {
355         manifest_text: signed_manifest,
356         uuid: manifest_uuid,
357       }
358     }
359     assert_response :success
360     assert_not_nil assigns(:object)
361     resp = JSON.parse(@response.body)
362     assert_equal manifest_uuid, resp['uuid']
363     assert_equal 48, resp['data_size']
364     # All of the locators in the output must be signed.
365     resp['manifest_text'].lines.each do |entry|
366       m = /([[:xdigit:]]{32}\+\S+)/.match(entry)
367       if m
368         assert Blob.verify_signature m[0], signing_opts
369       end
370     end
371   end
372
373   test "create fails with invalid signature" do
374     authorize_with :active
375     signing_opts = {
376       key: Rails.configuration.blob_signing_key,
377       api_token: api_token(:active),
378     }
379
380     # Generate a locator with a bad signature.
381     unsigned_locator = "d41d8cd98f00b204e9800998ecf8427e+0"
382     bad_locator = unsigned_locator + "+Affffffff@ffffffff"
383     assert !Blob.verify_signature(bad_locator, signing_opts)
384
385     # Creating a collection with this locator should
386     # produce 403 Permission denied.
387     unsigned_manifest = ". #{unsigned_locator} 0:0:foo.txt\n"
388     manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest) +
389       '+' +
390       unsigned_manifest.length.to_s
391
392     bad_manifest = ". #{bad_locator} 0:0:foo.txt\n"
393     post :create, {
394       collection: {
395         manifest_text: bad_manifest,
396         uuid: manifest_uuid
397       }
398     }
399
400     assert_response 403
401   end
402
403   test "create fails with uuid of signed manifest" do
404     authorize_with :active
405     signing_opts = {
406       key: Rails.configuration.blob_signing_key,
407       api_token: api_token(:active),
408     }
409
410     unsigned_locator = "d41d8cd98f00b204e9800998ecf8427e+0"
411     signed_locator = Blob.sign_locator(unsigned_locator, signing_opts)
412     signed_manifest = ". #{signed_locator} 0:0:foo.txt\n"
413     manifest_uuid = Digest::MD5.hexdigest(signed_manifest) +
414       '+' +
415       signed_manifest.length.to_s
416
417     post :create, {
418       collection: {
419         manifest_text: signed_manifest,
420         uuid: manifest_uuid
421       }
422     }
423
424     assert_response 422
425   end
426
427   test "multiple locators per line" do
428     permit_unsigned_manifests
429     authorize_with :active
430     locators = %w(
431       d41d8cd98f00b204e9800998ecf8427e+0
432       acbd18db4cc2f85cedef654fccc4a4d8+3
433       ea10d51bcf88862dbcc36eb292017dfd+45)
434
435     manifest_text = [".", *locators, "0:0:foo.txt\n"].join(" ")
436     manifest_uuid = Digest::MD5.hexdigest(manifest_text) +
437       '+' +
438       manifest_text.length.to_s
439
440     test_collection = {
441       manifest_text: manifest_text,
442       uuid: manifest_uuid,
443     }
444     post_collection = Marshal.load(Marshal.dump(test_collection))
445     post :create, {
446       collection: post_collection
447     }
448     assert_response :success
449     assert_not_nil assigns(:object)
450     resp = JSON.parse(@response.body)
451     assert_equal manifest_uuid, resp['uuid']
452     assert_equal 48, resp['data_size']
453
454     # The manifest in the response will have had permission hints added.
455     # Remove any permission hints in the response before comparing it to the source.
456     stripped_manifest = resp['manifest_text'].gsub(/\+A[A-Za-z0-9@_-]+/, '')
457     assert_equal manifest_text, stripped_manifest
458   end
459
460   test "multiple signed locators per line" do
461     permit_unsigned_manifests
462     authorize_with :active
463     locators = %w(
464       d41d8cd98f00b204e9800998ecf8427e+0
465       acbd18db4cc2f85cedef654fccc4a4d8+3
466       ea10d51bcf88862dbcc36eb292017dfd+45)
467
468     signing_opts = {
469       key: Rails.configuration.blob_signing_key,
470       api_token: api_token(:active),
471     }
472
473     unsigned_manifest = [".", *locators, "0:0:foo.txt\n"].join(" ")
474     manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest) +
475       '+' +
476       unsigned_manifest.length.to_s
477
478     signed_locators = locators.map { |loc| Blob.sign_locator loc, signing_opts }
479     signed_manifest = [".", *signed_locators, "0:0:foo.txt\n"].join(" ")
480
481     post :create, {
482       collection: {
483         manifest_text: signed_manifest,
484         uuid: manifest_uuid,
485       }
486     }
487     assert_response :success
488     assert_not_nil assigns(:object)
489     resp = JSON.parse(@response.body)
490     assert_equal manifest_uuid, resp['uuid']
491     assert_equal 48, resp['data_size']
492     # All of the locators in the output must be signed.
493     # Each line is of the form "path locator locator ... 0:0:file.txt"
494     # entry.split[1..-2] will yield just the tokens in the middle of the line
495     returned_locator_count = 0
496     resp['manifest_text'].lines.each do |entry|
497       entry.split[1..-2].each do |tok|
498         returned_locator_count += 1
499         assert Blob.verify_signature tok, signing_opts
500       end
501     end
502     assert_equal locators.count, returned_locator_count
503   end
504
505   test 'Reject manifest with unsigned blob' do
506     authorize_with :active
507     unsigned_manifest = ". 0cc175b9c0f1b6a831c399e269772661+1 0:1:a.txt\n"
508     manifest_uuid = Digest::MD5.hexdigest(unsigned_manifest)
509     post :create, {
510       collection: {
511         manifest_text: unsigned_manifest,
512         uuid: manifest_uuid,
513       }
514     }
515     assert_response 403,
516     "Creating a collection with unsigned blobs should respond 403"
517     assert_empty Collection.where('uuid like ?', manifest_uuid+'%'),
518     "Collection should not exist in database after failed create"
519   end
520 end