1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
18 "git.arvados.org/arvados.git/sdk/go/arvados"
19 "git.arvados.org/arvados.git/sdk/go/arvadosclient"
20 "git.arvados.org/arvados.git/sdk/go/arvadostest"
21 "git.arvados.org/arvados.git/sdk/go/ctxlog"
22 "git.arvados.org/arvados.git/sdk/go/keepclient"
23 "github.com/sirupsen/logrus"
27 type zipstage struct {
29 ac *arvadosclient.ArvadosClient
30 kc *keepclient.KeepClient
31 coll arvados.Collection
34 func (s *IntegrationSuite) zipsetup(c *C, filedata map[string]string) zipstage {
35 arv := arvados.NewClientFromEnv()
36 arv.AuthToken = arvadostest.ActiveToken
37 var coll arvados.Collection
38 err := arv.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{"collection": map[string]interface{}{
39 "name": "keep-web zip test collection",
40 "properties": map[string]interface{}{
43 "description": "Description of test collection\n",
46 ac, err := arvadosclient.New(arv)
48 kc, err := keepclient.MakeKeepClient(ac)
50 fs, err := coll.FileSystem(arv, kc)
52 for path, data := range filedata {
53 for i, c := range path {
55 fs.Mkdir(path[:i], 0777)
58 f, err := fs.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
60 _, err = f.Write([]byte(data))
67 err = arv.RequestAndDecode(&coll, "GET", "arvados/v1/collections/"+coll.UUID, nil, nil)
78 func (stage zipstage) teardown(c *C) {
79 if stage.coll.UUID != "" {
80 err := stage.arv.RequestAndDecode(&stage.coll, "DELETE", "arvados/v1/collections/"+stage.coll.UUID, nil, nil)
85 func (s *IntegrationSuite) TestZip_EmptyCollection(c *C) {
86 stage := s.zipsetup(c, nil)
87 defer stage.teardown(c)
88 _, resp := s.do("POST", s.collectionURL(stage.coll.UUID, ""), arvadostest.ActiveTokenV2, http.Header{"Accept": {"application/zip"}}, nil)
89 if !c.Check(resp.StatusCode, Equals, http.StatusOK) {
90 body, _ := io.ReadAll(resp.Body)
91 c.Logf("response body: %q", body)
94 zipdata, _ := io.ReadAll(resp.Body)
95 zipr, err := zip.NewReader(bytes.NewReader(zipdata), int64(len(zipdata)))
97 c.Check(zipr.File, HasLen, 0)
100 func (s *IntegrationSuite) TestZip_Metadata(c *C) {
101 s.testZip(c, testZipOptions{
103 reqQuery: "?include_collection_metadata=1",
104 reqToken: arvadostest.ActiveTokenV2,
106 expectFiles: []string{"collection.json", "dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
107 expectMetadata: map[string]interface{}{
108 "name": "keep-web zip test collection",
109 "portable_data_hash": "6acf043b102afcf04e3be2443e7ea2ba+223",
110 "properties": map[string]interface{}{
113 "uuid": "{{stage.coll.UUID}}",
114 "description": "Description of test collection\n",
115 "created_at": "{{stage.coll.CreatedAt}}",
116 "modified_at": "{{stage.coll.ModifiedAt}}",
117 "modified_by_user": map[string]interface{}{
118 "email": "active-user@arvados.local",
119 "full_name": "Active User",
120 "username": "active",
121 "uuid": arvadostest.ActiveUserUUID,
124 expectZipComment: `Downloaded from https://collections.example.com/by_id/{{stage.coll.UUID}}/`,
128 func (s *IntegrationSuite) TestZip_Logging(c *C) {
129 s.testZip(c, testZipOptions{
131 reqToken: arvadostest.ActiveTokenV2,
133 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
134 expectLogsMatch: []string{
135 `(?ms).*\smsg="File download".*`,
136 `(?ms).*\suser_uuid=` + arvadostest.ActiveUserUUID + `\s.*`,
137 `(?ms).*\suser_full_name="Active User".*`,
138 `(?ms).*\sportable_data_hash=6acf043b102afcf04e3be2443e7ea2ba\+223\s.*`,
139 `(?ms).*\scollection_file_path=\s.*`,
144 func (s *IntegrationSuite) TestZip_Logging_OneFile(c *C) {
145 s.testZip(c, testZipOptions{
147 reqContentType: "application/json",
148 reqToken: arvadostest.ActiveTokenV2,
149 reqBody: `{"files":["dir1/file1.txt"]}`,
151 expectFiles: []string{"dir1/file1.txt"},
152 expectLogsMatch: []string{
153 `(?ms).*\scollection_file_path=dir1/file1.txt\s.*`,
158 func (s *IntegrationSuite) TestZip_EntireCollection_GET(c *C) {
159 s.testZip(c, testZipOptions{
161 reqToken: arvadostest.ActiveTokenV2,
163 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
167 func (s *IntegrationSuite) TestZip_EntireCollection_JSON(c *C) {
168 s.testZip(c, testZipOptions{
170 reqContentType: "application/json",
171 reqToken: arvadostest.ActiveTokenV2,
172 reqBody: `{"files":[]}`,
174 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
178 func (s *IntegrationSuite) TestZip_EntireCollection_Slash(c *C) {
179 s.testZip(c, testZipOptions{
181 reqContentType: "application/json",
182 reqToken: arvadostest.ActiveTokenV2,
183 reqBody: `{"files":["/"]}`,
185 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
189 func (s *IntegrationSuite) TestZip_SelectDirectory_Form(c *C) {
190 s.testZip(c, testZipOptions{
192 reqContentType: "application/x-www-form-urlencoded",
193 reqToken: arvadostest.ActiveTokenV2,
194 reqBody: (url.Values{"files": {"dir1"}}).Encode(),
196 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
200 func (s *IntegrationSuite) TestZip_SelectDirectory_SpecifyDownloadFilename_Form(c *C) {
201 s.testZip(c, testZipOptions{
203 reqContentType: "application/x-www-form-urlencoded",
204 reqToken: arvadostest.ActiveTokenV2,
205 reqBody: (url.Values{"files": {"dir1"}, "download_filename": {"Foo Bar.zip"}}).Encode(),
207 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
208 expectDisposition: `attachment; filename="Foo Bar.zip"`,
212 func (s *IntegrationSuite) TestZip_SelectDirectory_JSON(c *C) {
213 s.testZip(c, testZipOptions{
215 reqContentType: "application/json",
216 reqToken: arvadostest.ActiveTokenV2,
217 reqBody: `{"files":["dir1"]}`,
219 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
220 expectDisposition: `attachment; filename="keep-web zip test collection - 2 files.zip"`,
224 func (s *IntegrationSuite) TestZip_SelectDirectory_TrailingSlash(c *C) {
225 s.testZip(c, testZipOptions{
227 reqContentType: "application/json",
228 reqToken: arvadostest.ActiveTokenV2,
229 reqBody: `{"files":["dir1/"]}`,
231 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
232 expectDisposition: `attachment; filename="keep-web zip test collection - 2 files.zip"`,
236 func (s *IntegrationSuite) TestZip_SelectDirectory_SpecifyDownloadFilename(c *C) {
237 s.testZip(c, testZipOptions{
239 reqContentType: "application/json",
240 reqToken: arvadostest.ActiveTokenV2,
241 reqBody: `{"files":["dir1/"],"download_filename":"Foo bar ⛵.zip"}`,
243 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
244 expectDisposition: `attachment; filename*=utf-8''Foo%20bar%20%E2%9B%B5.zip`,
248 func (s *IntegrationSuite) TestZip_SelectFile(c *C) {
249 s.testZip(c, testZipOptions{
251 reqContentType: "application/json",
252 reqToken: arvadostest.ActiveTokenV2,
253 reqBody: `{"files":["dir1/file1.txt"]}`,
255 expectFiles: []string{"dir1/file1.txt"},
256 expectDisposition: `attachment; filename="keep-web zip test collection - file1.txt.zip"`,
260 func (s *IntegrationSuite) TestZip_SelectFiles_Query(c *C) {
261 s.testZip(c, testZipOptions{
263 reqQuery: "?" + (&url.Values{"files": []string{"dir1/file1.txt", "dir2/file2.txt"}}).Encode(),
264 reqContentType: "application/json",
265 reqToken: arvadostest.ActiveTokenV2,
267 expectFiles: []string{"dir1/file1.txt", "dir2/file2.txt"},
268 expectDisposition: `attachment; filename="keep-web zip test collection - 2 files.zip"`,
272 func (s *IntegrationSuite) TestZip_SelectFiles_SpecifyDownloadFilename_Query(c *C) {
273 s.testZip(c, testZipOptions{
275 reqQuery: "?" + (&url.Values{
276 "files": []string{"dir1/file1.txt", "dir2/file2.txt"},
277 "download_filename": []string{"Sue.zip"},
279 reqContentType: "application/json",
280 reqToken: arvadostest.ActiveTokenV2,
282 expectFiles: []string{"dir1/file1.txt", "dir2/file2.txt"},
283 expectDisposition: `attachment; filename=Sue.zip`,
287 func (s *IntegrationSuite) TestZip_SpecifyDownloadFilename_NoZipExt(c *C) {
288 s.testZip(c, testZipOptions{
290 reqQuery: "?" + (&url.Values{
291 "download_filename": []string{"Sue.zap"},
293 reqContentType: "application/json",
294 reqToken: arvadostest.ActiveTokenV2,
296 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
297 expectDisposition: `attachment; filename=Sue.zap.zip`,
301 func (s *IntegrationSuite) TestZip_SelectFile_UseByIDStyle(c *C) {
302 s.testZip(c, testZipOptions{
305 reqContentType: "application/json",
306 reqToken: arvadostest.ActiveTokenV2,
307 reqBody: `{"files":["dir1/file1.txt"]}`,
309 expectFiles: []string{"dir1/file1.txt"},
310 expectDisposition: `attachment; filename="keep-web zip test collection - file1.txt.zip"`,
314 func (s *IntegrationSuite) TestZip_SelectFile_UsePathStyle(c *C) {
315 s.testZip(c, testZipOptions{
318 reqContentType: "application/json",
319 reqToken: arvadostest.ActiveTokenV2,
320 reqBody: `{"files":["dir1/file1.txt"]}`,
322 expectFiles: []string{"dir1/file1.txt"},
323 expectDisposition: `attachment; filename="keep-web zip test collection - file1.txt.zip"`,
327 func (s *IntegrationSuite) TestZip_SelectFile_UsePathStyle_PDH(c *C) {
328 s.testZip(c, testZipOptions{
332 reqQuery: "?include_collection_metadata=1",
333 reqContentType: "application/json",
334 reqToken: arvadostest.ActiveTokenV2,
335 reqBody: `{"files":["dir1/file1.txt"]}`,
337 expectFiles: []string{"collection.json", "dir1/file1.txt"},
338 expectDisposition: `attachment; filename="6acf043b102afcf04e3be2443e7ea2ba+223 - file1.txt.zip"`,
339 expectMetadata: map[string]interface{}{
340 "portable_data_hash": "6acf043b102afcf04e3be2443e7ea2ba+223",
342 expectZipComment: `Downloaded from http://collections.example.com/by_id/6acf043b102afcf04e3be2443e7ea2ba+223/`,
346 func (s *IntegrationSuite) TestZip_SelectRedundantFile(c *C) {
347 s.testZip(c, testZipOptions{
349 reqContentType: "application/json",
350 reqToken: arvadostest.ActiveTokenV2,
351 reqBody: `{"files":["dir1/dir", "dir1/dir/file1.txt"]}`,
353 expectFiles: []string{"dir1/dir/file1.txt"},
357 func (s *IntegrationSuite) TestZip_AcceptMediaTypeWithDirective(c *C) {
358 s.testZip(c, testZipOptions{
360 reqContentType: "application/json",
361 reqToken: arvadostest.ActiveTokenV2,
362 reqBody: `{"files":["dir1/dir/file1.txt"]}`,
363 reqAccept: `application/zip; q=0.9`,
365 expectFiles: []string{"dir1/dir/file1.txt"},
369 func (s *IntegrationSuite) TestZip_AcceptMediaTypeInQuery(c *C) {
370 s.testZip(c, testZipOptions{
372 reqContentType: "application/json",
373 reqToken: arvadostest.ActiveTokenV2,
374 reqBody: `{"files":["dir1/dir/file1.txt"]}`,
375 reqQuery: `?accept=application/zip&disposition=attachment`,
376 reqAccept: `text/html`,
378 expectFiles: []string{"dir1/dir/file1.txt"},
382 // disposition=attachment is implied, because usePathStyle causes
383 // testZip to use DownloadURL as the request vhost.
384 func (s *IntegrationSuite) TestZip_AcceptMediaTypeInQuery_ImplicitDisposition(c *C) {
385 s.testZip(c, testZipOptions{
388 reqContentType: "application/json",
389 reqToken: arvadostest.ActiveTokenV2,
390 reqBody: `{"files":["dir1/dir/file1.txt"]}`,
391 reqQuery: `?accept=application/zip`,
392 reqAccept: `text/html`,
394 expectFiles: []string{"dir1/dir/file1.txt"},
398 func (s *IntegrationSuite) TestZip_SelectNonexistentFile(c *C) {
399 s.testZip(c, testZipOptions{
401 reqContentType: "application/json",
402 reqToken: arvadostest.ActiveTokenV2,
403 reqBody: `{"files":["dir1", "file404.txt"]}`,
405 expectBodyMatch: `"file404.txt": file does not exist\n`,
409 func (s *IntegrationSuite) TestZip_SelectBlankFilename(c *C) {
410 s.testZip(c, testZipOptions{
412 reqContentType: "application/json",
413 reqToken: arvadostest.ActiveTokenV2,
414 reqBody: `{"files":[""]}`,
416 expectBodyMatch: `"": file does not exist\n`,
420 func (s *IntegrationSuite) TestZip_JSON_Error(c *C) {
421 s.testZip(c, testZipOptions{
423 reqContentType: "application/json",
424 reqToken: arvadostest.ActiveTokenV2,
425 reqBody: `{"files":["dir1/dir"`,
426 expectStatus: http.StatusBadRequest,
427 expectBodyMatch: `.*unexpected EOF.*\n`,
431 // Download-via-POST is still allowed if upload permission is turned
433 func (s *IntegrationSuite) TestZip_WebDAVPermission_OK(c *C) {
434 s.handler.Cluster.Collections.WebDAVPermission.User.Upload = false
435 s.testZip(c, testZipOptions{
437 reqContentType: "application/json",
438 reqToken: arvadostest.ActiveTokenV2,
439 expectFiles: []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
440 expectStatus: http.StatusOK,
444 func (s *IntegrationSuite) TestZip_WebDAVPermission_Forbidden(c *C) {
445 s.handler.Cluster.Collections.WebDAVPermission.User.Download = false
446 s.testZip(c, testZipOptions{
448 reqContentType: "application/json",
449 reqToken: arvadostest.ActiveTokenV2,
450 expectStatus: http.StatusForbidden,
451 expectBodyMatch: `Not permitted\n`,
455 type testZipOptions struct {
456 filedata map[string]string // if nil, use default set (see testZip)
463 reqContentType string
468 expectBodyMatch string
469 expectDisposition string
470 expectMetadata map[string]interface{}
471 expectZipComment string
472 expectLogsMatch []string
475 func (s *IntegrationSuite) testZip(c *C, opts testZipOptions) {
476 logbuf := new(bytes.Buffer)
477 logger := logrus.New()
478 logger.Out = io.MultiWriter(logbuf, ctxlog.LogWriter(c.Log))
479 s.ctx = ctxlog.Context(context.Background(), logger)
480 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "collections.example.com"
482 if opts.filedata == nil {
483 opts.filedata = map[string]string{
484 "dir1/dir/file1.txt": "file1",
485 "dir1/file1.txt": "file1",
486 "dir2/file2.txt": "file2",
487 "file0.txt": "file0",
490 stage := s.zipsetup(c, opts.filedata)
491 defer stage.teardown(c)
494 collID = stage.coll.PortableDataHash
496 collID = stage.coll.UUID
499 if opts.usePathStyle {
500 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Scheme = "http"
501 url = "http://collections.example.com/c=" + collID
502 } else if opts.useByIDStyle {
503 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Scheme = "http"
504 url = "http://collections.example.com/by_id/" + collID
506 url = s.collectionURL(collID, "")
509 if opts.reqAccept != "" {
510 accept = []string{opts.reqAccept}
512 accept = []string{"application/zip"}
514 _, resp := s.do(opts.reqMethod, url+opts.reqQuery, opts.reqToken, http.Header{
516 "Content-Type": {opts.reqContentType},
517 }, []byte(opts.reqBody))
518 if !c.Check(resp.StatusCode, Equals, opts.expectStatus) || opts.expectStatus != 200 {
519 body, _ := io.ReadAll(resp.Body)
520 c.Logf("response body: %q", body)
521 if opts.expectBodyMatch != "" {
522 c.Check(string(body), Matches, opts.expectBodyMatch)
526 zipdata, _ := io.ReadAll(resp.Body)
527 zipr, err := zip.NewReader(bytes.NewReader(zipdata), int64(len(zipdata)))
529 c.Check(zipFileNames(zipr), DeepEquals, opts.expectFiles)
530 if opts.expectDisposition != "" {
531 c.Check(resp.Header.Get("Content-Disposition"), Equals, opts.expectDisposition)
533 if opts.expectZipComment != "" {
534 c.Check(zipr.Comment, Equals, strings.Replace(opts.expectZipComment, "{{stage.coll.UUID}}", stage.coll.UUID, -1))
536 f, err := zipr.Open("collection.json")
537 c.Check(err == nil, Equals, opts.expectMetadata != nil,
538 Commentf("collection.json file existence (%v) did not match expectation (%v)", err == nil, opts.expectMetadata != nil))
541 if opts.expectMetadata["uuid"] == "{{stage.coll.UUID}}" {
542 opts.expectMetadata["uuid"] = stage.coll.UUID
544 if opts.expectMetadata["created_at"] == "{{stage.coll.CreatedAt}}" {
545 opts.expectMetadata["created_at"] = stage.coll.CreatedAt.Format(rfc3339NanoFixed)
547 if opts.expectMetadata["modified_at"] == "{{stage.coll.ModifiedAt}}" {
548 opts.expectMetadata["modified_at"] = stage.coll.ModifiedAt.Format(rfc3339NanoFixed)
550 var gotMetadata map[string]interface{}
551 json.NewDecoder(f).Decode(&gotMetadata)
552 c.Check(gotMetadata, DeepEquals, opts.expectMetadata)
554 for _, re := range opts.expectLogsMatch {
555 c.Check(logbuf.String(), Matches, re)
559 func zipFileNames(zipr *zip.Reader) []string {
561 for _, file := range zipr.File {
562 names = append(names, file.Name)