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