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