]> git.arvados.org - arvados.git/blob - services/keep-web/zip_test.go
22819: Fix message
[arvados.git] / services / keep-web / zip_test.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package keepweb
6
7 import (
8         "archive/zip"
9         "bytes"
10         "context"
11         "encoding/json"
12         "io"
13         "net/http"
14         "net/url"
15         "os"
16         "strings"
17
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"
24         . "gopkg.in/check.v1"
25 )
26
27 type zipstage struct {
28         arv  *arvados.Client
29         ac   *arvadosclient.ArvadosClient
30         kc   *keepclient.KeepClient
31         coll arvados.Collection
32 }
33
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{}{
41                         "sailboat": "⛵",
42                 },
43                 "description": "Description of test collection\n",
44         }})
45         c.Assert(err, IsNil)
46         ac, err := arvadosclient.New(arv)
47         c.Assert(err, IsNil)
48         kc, err := keepclient.MakeKeepClient(ac)
49         c.Assert(err, IsNil)
50         fs, err := coll.FileSystem(arv, kc)
51         c.Assert(err, IsNil)
52         for path, data := range filedata {
53                 for i, c := range path {
54                         if c == '/' {
55                                 fs.Mkdir(path[:i], 0777)
56                         }
57                 }
58                 f, err := fs.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
59                 c.Assert(err, IsNil)
60                 _, err = f.Write([]byte(data))
61                 c.Assert(err, IsNil)
62                 err = f.Close()
63                 c.Assert(err, IsNil)
64         }
65         err = fs.Sync()
66         c.Assert(err, IsNil)
67         err = arv.RequestAndDecode(&coll, "GET", "arvados/v1/collections/"+coll.UUID, nil, nil)
68         c.Assert(err, IsNil)
69
70         return zipstage{
71                 arv:  arv,
72                 ac:   ac,
73                 kc:   kc,
74                 coll: coll,
75         }
76 }
77
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)
81                 c.Check(err, IsNil)
82         }
83 }
84
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)
92                 return
93         }
94         zipdata, _ := io.ReadAll(resp.Body)
95         zipr, err := zip.NewReader(bytes.NewReader(zipdata), int64(len(zipdata)))
96         c.Assert(err, IsNil)
97         c.Check(zipr.File, HasLen, 0)
98 }
99
100 func (s *IntegrationSuite) TestZip_Metadata(c *C) {
101         s.testZip(c, testZipOptions{
102                 reqMethod:    "GET",
103                 reqQuery:     "?include_collection_metadata=1",
104                 reqToken:     arvadostest.ActiveTokenV2,
105                 expectStatus: 200,
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{}{
111                                 "sailboat": "⛵",
112                         },
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,
122                         },
123                 },
124                 expectZipComment: `Downloaded from https://collections.example.com/by_id/{{stage.coll.UUID}}/`,
125         })
126 }
127
128 func (s *IntegrationSuite) TestZip_Logging(c *C) {
129         s.testZip(c, testZipOptions{
130                 reqMethod:    "POST",
131                 reqToken:     arvadostest.ActiveTokenV2,
132                 expectStatus: 200,
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.*`,
140                 },
141         })
142 }
143
144 func (s *IntegrationSuite) TestZip_Logging_OneFile(c *C) {
145         s.testZip(c, testZipOptions{
146                 reqMethod:      "POST",
147                 reqContentType: "application/json",
148                 reqToken:       arvadostest.ActiveTokenV2,
149                 reqBody:        `{"files":["dir1/file1.txt"]}`,
150                 expectStatus:   200,
151                 expectFiles:    []string{"dir1/file1.txt"},
152                 expectLogsMatch: []string{
153                         `(?ms).*\scollection_file_path=dir1/file1.txt\s.*`,
154                 },
155         })
156 }
157
158 func (s *IntegrationSuite) TestZip_EntireCollection_GET(c *C) {
159         s.testZip(c, testZipOptions{
160                 reqMethod:    "GET",
161                 reqToken:     arvadostest.ActiveTokenV2,
162                 expectStatus: 200,
163                 expectFiles:  []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
164         })
165 }
166
167 func (s *IntegrationSuite) TestZip_EntireCollection_JSON(c *C) {
168         s.testZip(c, testZipOptions{
169                 reqMethod:      "GET",
170                 reqContentType: "application/json",
171                 reqToken:       arvadostest.ActiveTokenV2,
172                 reqBody:        `{"files":[]}`,
173                 expectStatus:   200,
174                 expectFiles:    []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
175         })
176 }
177
178 func (s *IntegrationSuite) TestZip_EntireCollection_Slash(c *C) {
179         s.testZip(c, testZipOptions{
180                 reqMethod:      "GET",
181                 reqContentType: "application/json",
182                 reqToken:       arvadostest.ActiveTokenV2,
183                 reqBody:        `{"files":["/"]}`,
184                 expectStatus:   200,
185                 expectFiles:    []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
186         })
187 }
188
189 func (s *IntegrationSuite) TestZip_SelectDirectory_Form(c *C) {
190         s.testZip(c, testZipOptions{
191                 reqMethod:      "POST",
192                 reqContentType: "application/x-www-form-urlencoded",
193                 reqToken:       arvadostest.ActiveTokenV2,
194                 reqBody:        (url.Values{"files": {"dir1"}}).Encode(),
195                 expectStatus:   200,
196                 expectFiles:    []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
197         })
198 }
199
200 func (s *IntegrationSuite) TestZip_SelectDirectory_SpecifyDownloadFilename_Form(c *C) {
201         s.testZip(c, testZipOptions{
202                 reqMethod:         "POST",
203                 reqContentType:    "application/x-www-form-urlencoded",
204                 reqToken:          arvadostest.ActiveTokenV2,
205                 reqBody:           (url.Values{"files": {"dir1"}, "download_filename": {"Foo Bar.zip"}}).Encode(),
206                 expectStatus:      200,
207                 expectFiles:       []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
208                 expectDisposition: `attachment; filename="Foo Bar.zip"`,
209         })
210 }
211
212 func (s *IntegrationSuite) TestZip_SelectDirectory_JSON(c *C) {
213         s.testZip(c, testZipOptions{
214                 reqMethod:         "POST",
215                 reqContentType:    "application/json",
216                 reqToken:          arvadostest.ActiveTokenV2,
217                 reqBody:           `{"files":["dir1"]}`,
218                 expectStatus:      200,
219                 expectFiles:       []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
220                 expectDisposition: `attachment; filename="keep-web zip test collection - 2 files.zip"`,
221         })
222 }
223
224 func (s *IntegrationSuite) TestZip_SelectDirectory_TrailingSlash(c *C) {
225         s.testZip(c, testZipOptions{
226                 reqMethod:         "POST",
227                 reqContentType:    "application/json",
228                 reqToken:          arvadostest.ActiveTokenV2,
229                 reqBody:           `{"files":["dir1/"]}`,
230                 expectStatus:      200,
231                 expectFiles:       []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
232                 expectDisposition: `attachment; filename="keep-web zip test collection - 2 files.zip"`,
233         })
234 }
235
236 func (s *IntegrationSuite) TestZip_SelectDirectory_SpecifyDownloadFilename(c *C) {
237         s.testZip(c, testZipOptions{
238                 reqMethod:         "POST",
239                 reqContentType:    "application/json",
240                 reqToken:          arvadostest.ActiveTokenV2,
241                 reqBody:           `{"files":["dir1/"],"download_filename":"Foo bar ⛵.zip"}`,
242                 expectStatus:      200,
243                 expectFiles:       []string{"dir1/dir/file1.txt", "dir1/file1.txt"},
244                 expectDisposition: `attachment; filename*=utf-8''Foo%20bar%20%E2%9B%B5.zip`,
245         })
246 }
247
248 func (s *IntegrationSuite) TestZip_SelectFile(c *C) {
249         s.testZip(c, testZipOptions{
250                 reqMethod:         "POST",
251                 reqContentType:    "application/json",
252                 reqToken:          arvadostest.ActiveTokenV2,
253                 reqBody:           `{"files":["dir1/file1.txt"]}`,
254                 expectStatus:      200,
255                 expectFiles:       []string{"dir1/file1.txt"},
256                 expectDisposition: `attachment; filename="keep-web zip test collection - file1.txt.zip"`,
257         })
258 }
259
260 func (s *IntegrationSuite) TestZip_SelectFiles_Query(c *C) {
261         s.testZip(c, testZipOptions{
262                 reqMethod:         "POST",
263                 reqQuery:          "?" + (&url.Values{"files": []string{"dir1/file1.txt", "dir2/file2.txt"}}).Encode(),
264                 reqContentType:    "application/json",
265                 reqToken:          arvadostest.ActiveTokenV2,
266                 expectStatus:      200,
267                 expectFiles:       []string{"dir1/file1.txt", "dir2/file2.txt"},
268                 expectDisposition: `attachment; filename="keep-web zip test collection - 2 files.zip"`,
269         })
270 }
271
272 func (s *IntegrationSuite) TestZip_SelectFiles_SpecifyDownloadFilename_Query(c *C) {
273         s.testZip(c, testZipOptions{
274                 reqMethod: "POST",
275                 reqQuery: "?" + (&url.Values{
276                         "files":             []string{"dir1/file1.txt", "dir2/file2.txt"},
277                         "download_filename": []string{"Sue.zip"},
278                 }).Encode(),
279                 reqContentType:    "application/json",
280                 reqToken:          arvadostest.ActiveTokenV2,
281                 expectStatus:      200,
282                 expectFiles:       []string{"dir1/file1.txt", "dir2/file2.txt"},
283                 expectDisposition: `attachment; filename=Sue.zip`,
284         })
285 }
286
287 func (s *IntegrationSuite) TestZip_SpecifyDownloadFilename_NoZipExt(c *C) {
288         s.testZip(c, testZipOptions{
289                 reqMethod: "GET",
290                 reqQuery: "?" + (&url.Values{
291                         "download_filename": []string{"Sue.zap"},
292                 }).Encode(),
293                 reqContentType:    "application/json",
294                 reqToken:          arvadostest.ActiveTokenV2,
295                 expectStatus:      200,
296                 expectFiles:       []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
297                 expectDisposition: `attachment; filename=Sue.zap.zip`,
298         })
299 }
300
301 func (s *IntegrationSuite) TestZip_SelectFile_UseByIDStyle(c *C) {
302         s.testZip(c, testZipOptions{
303                 useByIDStyle:      true,
304                 reqMethod:         "POST",
305                 reqContentType:    "application/json",
306                 reqToken:          arvadostest.ActiveTokenV2,
307                 reqBody:           `{"files":["dir1/file1.txt"]}`,
308                 expectStatus:      200,
309                 expectFiles:       []string{"dir1/file1.txt"},
310                 expectDisposition: `attachment; filename="keep-web zip test collection - file1.txt.zip"`,
311         })
312 }
313
314 func (s *IntegrationSuite) TestZip_SelectFile_UsePathStyle(c *C) {
315         s.testZip(c, testZipOptions{
316                 usePathStyle:      true,
317                 reqMethod:         "POST",
318                 reqContentType:    "application/json",
319                 reqToken:          arvadostest.ActiveTokenV2,
320                 reqBody:           `{"files":["dir1/file1.txt"]}`,
321                 expectStatus:      200,
322                 expectFiles:       []string{"dir1/file1.txt"},
323                 expectDisposition: `attachment; filename="keep-web zip test collection - file1.txt.zip"`,
324         })
325 }
326
327 func (s *IntegrationSuite) TestZip_SelectFile_UsePathStyle_PDH(c *C) {
328         s.testZip(c, testZipOptions{
329                 usePathStyle:      true,
330                 usePDH:            true,
331                 reqMethod:         "POST",
332                 reqQuery:          "?include_collection_metadata=1",
333                 reqContentType:    "application/json",
334                 reqToken:          arvadostest.ActiveTokenV2,
335                 reqBody:           `{"files":["dir1/file1.txt"]}`,
336                 expectStatus:      200,
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",
341                 },
342                 expectZipComment: `Downloaded from http://collections.example.com/by_id/6acf043b102afcf04e3be2443e7ea2ba+223/`,
343         })
344 }
345
346 func (s *IntegrationSuite) TestZip_SelectRedundantFile(c *C) {
347         s.testZip(c, testZipOptions{
348                 reqMethod:      "POST",
349                 reqContentType: "application/json",
350                 reqToken:       arvadostest.ActiveTokenV2,
351                 reqBody:        `{"files":["dir1/dir", "dir1/dir/file1.txt"]}`,
352                 expectStatus:   200,
353                 expectFiles:    []string{"dir1/dir/file1.txt"},
354         })
355 }
356
357 func (s *IntegrationSuite) TestZip_AcceptMediaTypeWithDirective(c *C) {
358         s.testZip(c, testZipOptions{
359                 reqMethod:      "POST",
360                 reqContentType: "application/json",
361                 reqToken:       arvadostest.ActiveTokenV2,
362                 reqBody:        `{"files":["dir1/dir/file1.txt"]}`,
363                 reqAccept:      `application/zip; q=0.9`,
364                 expectStatus:   200,
365                 expectFiles:    []string{"dir1/dir/file1.txt"},
366         })
367 }
368
369 func (s *IntegrationSuite) TestZip_SelectNonexistentFile(c *C) {
370         s.testZip(c, testZipOptions{
371                 reqMethod:       "POST",
372                 reqContentType:  "application/json",
373                 reqToken:        arvadostest.ActiveTokenV2,
374                 reqBody:         `{"files":["dir1", "file404.txt"]}`,
375                 expectStatus:    404,
376                 expectBodyMatch: `"file404.txt": file does not exist\n`,
377         })
378 }
379
380 func (s *IntegrationSuite) TestZip_SelectBlankFilename(c *C) {
381         s.testZip(c, testZipOptions{
382                 reqMethod:       "POST",
383                 reqContentType:  "application/json",
384                 reqToken:        arvadostest.ActiveTokenV2,
385                 reqBody:         `{"files":[""]}`,
386                 expectStatus:    404,
387                 expectBodyMatch: `"": file does not exist\n`,
388         })
389 }
390
391 func (s *IntegrationSuite) TestZip_JSON_Error(c *C) {
392         s.testZip(c, testZipOptions{
393                 reqMethod:       "POST",
394                 reqContentType:  "application/json",
395                 reqToken:        arvadostest.ActiveTokenV2,
396                 reqBody:         `{"files":["dir1/dir"`,
397                 expectStatus:    http.StatusBadRequest,
398                 expectBodyMatch: `.*unexpected EOF.*\n`,
399         })
400 }
401
402 // Download-via-POST is still allowed if upload permission is turned
403 // off.
404 func (s *IntegrationSuite) TestZip_WebDAVPermission_OK(c *C) {
405         s.handler.Cluster.Collections.WebDAVPermission.User.Upload = false
406         s.testZip(c, testZipOptions{
407                 reqMethod:      "POST",
408                 reqContentType: "application/json",
409                 reqToken:       arvadostest.ActiveTokenV2,
410                 expectFiles:    []string{"dir1/dir/file1.txt", "dir1/file1.txt", "dir2/file2.txt", "file0.txt"},
411                 expectStatus:   http.StatusOK,
412         })
413 }
414
415 func (s *IntegrationSuite) TestZip_WebDAVPermission_Forbidden(c *C) {
416         s.handler.Cluster.Collections.WebDAVPermission.User.Download = false
417         s.testZip(c, testZipOptions{
418                 reqMethod:       "POST",
419                 reqContentType:  "application/json",
420                 reqToken:        arvadostest.ActiveTokenV2,
421                 expectStatus:    http.StatusForbidden,
422                 expectBodyMatch: `Not permitted\n`,
423         })
424 }
425
426 type testZipOptions struct {
427         filedata          map[string]string // if nil, use default set (see testZip)
428         usePDH            bool
429         usePathStyle      bool
430         useByIDStyle      bool
431         reqMethod         string
432         reqQuery          string
433         reqAccept         string
434         reqContentType    string
435         reqToken          string
436         reqBody           string
437         expectStatus      int
438         expectFiles       []string
439         expectBodyMatch   string
440         expectDisposition string
441         expectMetadata    map[string]interface{}
442         expectZipComment  string
443         expectLogsMatch   []string
444 }
445
446 func (s *IntegrationSuite) testZip(c *C, opts testZipOptions) {
447         logbuf := new(bytes.Buffer)
448         logger := logrus.New()
449         logger.Out = io.MultiWriter(logbuf, ctxlog.LogWriter(c.Log))
450         s.ctx = ctxlog.Context(context.Background(), logger)
451         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "collections.example.com"
452
453         if opts.filedata == nil {
454                 opts.filedata = map[string]string{
455                         "dir1/dir/file1.txt": "file1",
456                         "dir1/file1.txt":     "file1",
457                         "dir2/file2.txt":     "file2",
458                         "file0.txt":          "file0",
459                 }
460         }
461         stage := s.zipsetup(c, opts.filedata)
462         defer stage.teardown(c)
463         var collID string
464         if opts.usePDH {
465                 collID = stage.coll.PortableDataHash
466         } else {
467                 collID = stage.coll.UUID
468         }
469         var url string
470         if opts.usePathStyle {
471                 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Scheme = "http"
472                 url = "http://collections.example.com/c=" + collID
473         } else if opts.useByIDStyle {
474                 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Scheme = "http"
475                 url = "http://collections.example.com/by_id/" + collID
476         } else {
477                 url = s.collectionURL(collID, "")
478         }
479         var accept []string
480         if opts.reqAccept != "" {
481                 accept = []string{opts.reqAccept}
482         } else {
483                 accept = []string{"application/zip"}
484         }
485         _, resp := s.do(opts.reqMethod, url+opts.reqQuery, opts.reqToken, http.Header{
486                 "Accept":       accept,
487                 "Content-Type": {opts.reqContentType},
488         }, []byte(opts.reqBody))
489         if !c.Check(resp.StatusCode, Equals, opts.expectStatus) || opts.expectStatus != 200 {
490                 body, _ := io.ReadAll(resp.Body)
491                 c.Logf("response body: %q", body)
492                 if opts.expectBodyMatch != "" {
493                         c.Check(string(body), Matches, opts.expectBodyMatch)
494                 }
495                 return
496         }
497         zipdata, _ := io.ReadAll(resp.Body)
498         zipr, err := zip.NewReader(bytes.NewReader(zipdata), int64(len(zipdata)))
499         c.Assert(err, IsNil)
500         c.Check(zipFileNames(zipr), DeepEquals, opts.expectFiles)
501         if opts.expectDisposition != "" {
502                 c.Check(resp.Header.Get("Content-Disposition"), Equals, opts.expectDisposition)
503         }
504         if opts.expectZipComment != "" {
505                 c.Check(zipr.Comment, Equals, strings.Replace(opts.expectZipComment, "{{stage.coll.UUID}}", stage.coll.UUID, -1))
506         }
507         f, err := zipr.Open("collection.json")
508         c.Check(err == nil, Equals, opts.expectMetadata != nil,
509                 Commentf("collection.json file existence (%v) did not match expectation (%v)", err == nil, opts.expectMetadata != nil))
510         if err == nil {
511                 defer f.Close()
512                 if opts.expectMetadata["uuid"] == "{{stage.coll.UUID}}" {
513                         opts.expectMetadata["uuid"] = stage.coll.UUID
514                 }
515                 if opts.expectMetadata["created_at"] == "{{stage.coll.CreatedAt}}" {
516                         opts.expectMetadata["created_at"] = stage.coll.CreatedAt.Format(rfc3339NanoFixed)
517                 }
518                 if opts.expectMetadata["modified_at"] == "{{stage.coll.ModifiedAt}}" {
519                         opts.expectMetadata["modified_at"] = stage.coll.ModifiedAt.Format(rfc3339NanoFixed)
520                 }
521                 var gotMetadata map[string]interface{}
522                 json.NewDecoder(f).Decode(&gotMetadata)
523                 c.Check(gotMetadata, DeepEquals, opts.expectMetadata)
524         }
525         for _, re := range opts.expectLogsMatch {
526                 c.Check(logbuf.String(), Matches, re)
527         }
528 }
529
530 func zipFileNames(zipr *zip.Reader) []string {
531         var names []string
532         for _, file := range zipr.File {
533                 names = append(names, file.Name)
534         }
535         return names
536 }