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