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