21702: Merge branch 'main' into 21702-keep-web-replace_files
[arvados.git] / services / keep-web / handler_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         "bytes"
9         "context"
10         "fmt"
11         "io"
12         "io/ioutil"
13         "net/http"
14         "net/http/httptest"
15         "net/url"
16         "os"
17         "path/filepath"
18         "regexp"
19         "strings"
20         "sync"
21         "time"
22
23         "git.arvados.org/arvados.git/lib/config"
24         "git.arvados.org/arvados.git/sdk/go/arvados"
25         "git.arvados.org/arvados.git/sdk/go/arvadosclient"
26         "git.arvados.org/arvados.git/sdk/go/arvadostest"
27         "git.arvados.org/arvados.git/sdk/go/auth"
28         "git.arvados.org/arvados.git/sdk/go/ctxlog"
29         "git.arvados.org/arvados.git/sdk/go/keepclient"
30         "github.com/prometheus/client_golang/prometheus"
31         "github.com/sirupsen/logrus"
32         "golang.org/x/net/html"
33         check "gopkg.in/check.v1"
34 )
35
36 var _ = check.Suite(&UnitSuite{})
37
38 func init() {
39         arvados.DebugLocksPanicMode = true
40 }
41
42 type UnitSuite struct {
43         cluster *arvados.Cluster
44         handler *handler
45 }
46
47 func (s *UnitSuite) SetUpTest(c *check.C) {
48         logger := ctxlog.TestLogger(c)
49         ldr := config.NewLoader(bytes.NewBufferString("Clusters: {zzzzz: {}}"), logger)
50         ldr.Path = "-"
51         cfg, err := ldr.Load()
52         c.Assert(err, check.IsNil)
53         cc, err := cfg.GetCluster("")
54         c.Assert(err, check.IsNil)
55         s.cluster = cc
56         s.handler = &handler{
57                 Cluster: cc,
58                 Cache: cache{
59                         cluster:  cc,
60                         logger:   logger,
61                         registry: prometheus.NewRegistry(),
62                 },
63                 metrics: newMetrics(prometheus.NewRegistry()),
64         }
65 }
66
67 func newCollection(collID string) *arvados.Collection {
68         coll := &arvados.Collection{UUID: collID}
69         manifestKey := collID
70         if pdh, ok := arvadostest.TestCollectionUUIDToPDH[collID]; ok {
71                 coll.PortableDataHash = pdh
72                 manifestKey = pdh
73         }
74         if mtext, ok := arvadostest.TestCollectionPDHToManifest[manifestKey]; ok {
75                 coll.ManifestText = mtext
76         }
77         return coll
78 }
79
80 func newRequest(method, urlStr string) *http.Request {
81         u := mustParseURL(urlStr)
82         return &http.Request{
83                 Method:     method,
84                 Host:       u.Host,
85                 URL:        u,
86                 RequestURI: u.RequestURI(),
87                 RemoteAddr: "10.20.30.40:56789",
88                 Header:     http.Header{},
89         }
90 }
91
92 func newLoggerAndContext() (*bytes.Buffer, context.Context) {
93         var logbuf bytes.Buffer
94         logger := logrus.New()
95         logger.Out = &logbuf
96         return &logbuf, ctxlog.Context(context.Background(), logger)
97 }
98
99 func (s *UnitSuite) TestLogEventTypes(c *check.C) {
100         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
101         for method, expected := range map[string]string{
102                 "GET":  "file_download",
103                 "POST": "file_upload",
104                 "PUT":  "file_upload",
105         } {
106                 filePath := "/" + method
107                 req := newRequest(method, collURL+filePath)
108                 actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
109                 if !c.Check(actual, check.NotNil) {
110                         continue
111                 }
112                 c.Check(actual.eventType, check.Equals, expected)
113         }
114 }
115
116 func (s *UnitSuite) TestUnloggedEventTypes(c *check.C) {
117         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
118         for _, method := range []string{"DELETE", "HEAD", "OPTIONS", "PATCH"} {
119                 filePath := "/" + method
120                 req := newRequest(method, collURL+filePath)
121                 actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
122                 c.Check(actual, check.IsNil,
123                         check.Commentf("%s request made a log event", method))
124         }
125 }
126
127 func (s *UnitSuite) TestLogFilePath(c *check.C) {
128         coll := newCollection(arvadostest.FooCollection)
129         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
130         for _, filePath := range []string{"/foo", "/Foo", "/foo/bar"} {
131                 req := newRequest("GET", collURL+filePath)
132                 actual := newFileEventLog(s.handler, req, filePath, coll, nil, "")
133                 if !c.Check(actual, check.NotNil) {
134                         continue
135                 }
136                 c.Check(actual.collFilePath, check.Equals, filePath)
137         }
138 }
139
140 func (s *UnitSuite) TestLogRemoteAddr(c *check.C) {
141         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
142         filePath := "/foo"
143         req := newRequest("GET", collURL+filePath)
144
145         for _, addr := range []string{"10.20.30.55", "192.168.144.120", "192.0.2.4"} {
146                 req.RemoteAddr = addr + ":57914"
147                 actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
148                 if !c.Check(actual, check.NotNil) {
149                         continue
150                 }
151                 c.Check(actual.clientAddr, check.Equals, addr)
152         }
153
154         for _, addr := range []string{"100::20:30:40", "2001:db8::90:100", "3fff::30"} {
155                 req.RemoteAddr = fmt.Sprintf("[%s]:57916", addr)
156                 actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
157                 if !c.Check(actual, check.NotNil) {
158                         continue
159                 }
160                 c.Check(actual.clientAddr, check.Equals, addr)
161         }
162 }
163
164 func (s *UnitSuite) TestLogXForwardedFor(c *check.C) {
165         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
166         filePath := "/foo"
167         req := newRequest("GET", collURL+filePath)
168         for xff, expected := range map[string]string{
169                 "10.20.30.55":                          "10.20.30.55",
170                 "192.168.144.120, 10.20.30.120":        "10.20.30.120",
171                 "192.0.2.4, 192.0.2.6, 192.0.2.8":      "192.0.2.8",
172                 "192.0.2.4,192.168.2.4":                "192.168.2.4",
173                 "10.20.30.60,192.168.144.40,192.0.2.4": "192.0.2.4",
174                 "100::20:30:50":                        "100::20:30:50",
175                 "2001:db8::80:90, 100::100":            "100::100",
176                 "3fff::ff, 3fff::ee, 3fff::fe":         "3fff::fe",
177                 "3fff::3f,100::1000":                   "100::1000",
178                 "2001:db8::88,100::88,3fff::88":        "3fff::88",
179                 "10.20.30.60, 2001:db8::60":            "2001:db8::60",
180                 "2001:db8::20,10.20.30.20":             "10.20.30.20",
181                 ", 10.20.30.123, 100::123":             "100::123",
182                 ",100::321,10.30.20.10":                "10.30.20.10",
183         } {
184                 req.Header.Set("X-Forwarded-For", xff)
185                 actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
186                 if !c.Check(actual, check.NotNil) {
187                         continue
188                 }
189                 c.Check(actual.clientAddr, check.Equals, expected)
190         }
191 }
192
193 func (s *UnitSuite) TestLogXForwardedForMalformed(c *check.C) {
194         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
195         filePath := "/foo"
196         req := newRequest("GET", collURL+filePath)
197         for _, xff := range []string{"", ",", "10.20,30.40", "foo, bar"} {
198                 req.Header.Set("X-Forwarded-For", xff)
199                 actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
200                 if !c.Check(actual, check.NotNil) {
201                         continue
202                 }
203                 c.Check(actual.clientAddr, check.Equals, "10.20.30.40")
204         }
205 }
206
207 func (s *UnitSuite) TestLogXForwardedForMultivalue(c *check.C) {
208         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
209         filePath := "/foo"
210         req := newRequest("GET", collURL+filePath)
211         req.Header.Set("X-Forwarded-For", ", ")
212         req.Header.Add("X-Forwarded-For", "2001:db8::db9:dbd")
213         req.Header.Add("X-Forwarded-For", "10.20.30.90")
214         actual := newFileEventLog(s.handler, req, filePath, nil, nil, "")
215         c.Assert(actual, check.NotNil)
216         c.Check(actual.clientAddr, check.Equals, "10.20.30.90")
217 }
218
219 func (s *UnitSuite) TestLogClientAddressCanonicalization(c *check.C) {
220         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
221         filePath := "/foo"
222         req := newRequest("GET", collURL+filePath)
223         expected := "2001:db8::12:0"
224
225         req.RemoteAddr = "[2001:db8::012:0000]:57918"
226         a := newFileEventLog(s.handler, req, filePath, nil, nil, "")
227         c.Assert(a, check.NotNil)
228         c.Check(a.clientAddr, check.Equals, expected)
229
230         req.RemoteAddr = "10.20.30.40:57919"
231         req.Header.Set("X-Forwarded-For", "2001:db8:0::0:12:00")
232         b := newFileEventLog(s.handler, req, filePath, nil, nil, "")
233         c.Assert(b, check.NotNil)
234         c.Check(b.clientAddr, check.Equals, expected)
235 }
236
237 func (s *UnitSuite) TestLogAnonymousUser(c *check.C) {
238         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
239         filePath := "/foo"
240         req := newRequest("GET", collURL+filePath)
241         actual := newFileEventLog(s.handler, req, filePath, nil, nil, arvadostest.AnonymousToken)
242         c.Assert(actual, check.NotNil)
243         c.Check(actual.userUUID, check.Equals, s.handler.Cluster.ClusterID+"-tpzed-anonymouspublic")
244         c.Check(actual.userFullName, check.Equals, "")
245         c.Check(actual.clientToken, check.Equals, arvadostest.AnonymousToken)
246 }
247
248 func (s *UnitSuite) TestLogUser(c *check.C) {
249         collURL := "http://keep-web.example/c=" + arvadostest.FooCollection
250         for _, trial := range []struct{ uuid, fullName, token string }{
251                 {arvadostest.ActiveUserUUID, "Active User", arvadostest.ActiveToken},
252                 {arvadostest.SpectatorUserUUID, "Spectator User", arvadostest.SpectatorToken},
253         } {
254                 filePath := "/" + trial.uuid
255                 req := newRequest("GET", collURL+filePath)
256                 user := &arvados.User{
257                         UUID:     trial.uuid,
258                         FullName: trial.fullName,
259                 }
260                 actual := newFileEventLog(s.handler, req, filePath, nil, user, trial.token)
261                 if !c.Check(actual, check.NotNil) {
262                         continue
263                 }
264                 c.Check(actual.userUUID, check.Equals, trial.uuid)
265                 c.Check(actual.userFullName, check.Equals, trial.fullName)
266                 c.Check(actual.clientToken, check.Equals, trial.token)
267         }
268 }
269
270 func (s *UnitSuite) TestLogCollectionByUUID(c *check.C) {
271         for collUUID, collPDH := range arvadostest.TestCollectionUUIDToPDH {
272                 collURL := "http://keep-web.example/c=" + collUUID
273                 filePath := "/" + collUUID
274                 req := newRequest("GET", collURL+filePath)
275                 coll := newCollection(collUUID)
276                 actual := newFileEventLog(s.handler, req, filePath, coll, nil, "")
277                 if !c.Check(actual, check.NotNil) {
278                         continue
279                 }
280                 c.Check(actual.collUUID, check.Equals, collUUID)
281                 c.Check(actual.collPDH, check.Equals, collPDH)
282         }
283 }
284
285 func (s *UnitSuite) TestLogCollectionByPDH(c *check.C) {
286         for _, collPDH := range arvadostest.TestCollectionUUIDToPDH {
287                 collURL := "http://keep-web.example/c=" + collPDH
288                 filePath := "/PDHFile"
289                 req := newRequest("GET", collURL+filePath)
290                 coll := newCollection(collPDH)
291                 actual := newFileEventLog(s.handler, req, filePath, coll, nil, "")
292                 if !c.Check(actual, check.NotNil) {
293                         continue
294                 }
295                 c.Check(actual.collPDH, check.Equals, collPDH)
296                 c.Check(actual.collUUID, check.Equals, "")
297         }
298 }
299
300 func (s *UnitSuite) TestLogGETUUIDAsDict(c *check.C) {
301         filePath := "/foo"
302         reqPath := "/c=" + arvadostest.FooCollection + filePath
303         req := newRequest("GET", "http://keep-web.example"+reqPath)
304         coll := newCollection(arvadostest.FooCollection)
305         logEvent := newFileEventLog(s.handler, req, filePath, coll, nil, "")
306         c.Assert(logEvent, check.NotNil)
307         c.Check(logEvent.asDict(), check.DeepEquals, arvadosclient.Dict{
308                 "event_type":  "file_download",
309                 "object_uuid": s.handler.Cluster.ClusterID + "-tpzed-anonymouspublic",
310                 "properties": arvadosclient.Dict{
311                         "reqPath":              reqPath,
312                         "collection_uuid":      arvadostest.FooCollection,
313                         "collection_file_path": filePath,
314                         "portable_data_hash":   arvadostest.FooCollectionPDH,
315                 },
316         })
317 }
318
319 func (s *UnitSuite) TestLogGETPDHAsDict(c *check.C) {
320         filePath := "/Foo"
321         reqPath := "/c=" + arvadostest.FooCollectionPDH + filePath
322         req := newRequest("GET", "http://keep-web.example"+reqPath)
323         coll := newCollection(arvadostest.FooCollectionPDH)
324         user := &arvados.User{
325                 UUID:     arvadostest.ActiveUserUUID,
326                 FullName: "Active User",
327         }
328         logEvent := newFileEventLog(s.handler, req, filePath, coll, user, "")
329         c.Assert(logEvent, check.NotNil)
330         c.Check(logEvent.asDict(), check.DeepEquals, arvadosclient.Dict{
331                 "event_type":  "file_download",
332                 "object_uuid": arvadostest.ActiveUserUUID,
333                 "properties": arvadosclient.Dict{
334                         "reqPath":              reqPath,
335                         "portable_data_hash":   arvadostest.FooCollectionPDH,
336                         "collection_uuid":      "",
337                         "collection_file_path": filePath,
338                 },
339         })
340 }
341
342 func (s *UnitSuite) TestLogUploadAsDict(c *check.C) {
343         coll := newCollection(arvadostest.FooCollection)
344         user := &arvados.User{
345                 UUID:     arvadostest.ActiveUserUUID,
346                 FullName: "Active User",
347         }
348         for _, method := range []string{"POST", "PUT"} {
349                 filePath := "/" + method + "File"
350                 reqPath := "/c=" + arvadostest.FooCollection + filePath
351                 req := newRequest(method, "http://keep-web.example"+reqPath)
352                 logEvent := newFileEventLog(s.handler, req, filePath, coll, user, "")
353                 if !c.Check(logEvent, check.NotNil) {
354                         continue
355                 }
356                 c.Check(logEvent.asDict(), check.DeepEquals, arvadosclient.Dict{
357                         "event_type":  "file_upload",
358                         "object_uuid": arvadostest.ActiveUserUUID,
359                         "properties": arvadosclient.Dict{
360                                 "reqPath":              reqPath,
361                                 "collection_uuid":      arvadostest.FooCollection,
362                                 "collection_file_path": filePath,
363                         },
364                 })
365         }
366 }
367
368 func (s *UnitSuite) TestLogGETUUIDAsFields(c *check.C) {
369         filePath := "/foo"
370         reqPath := "/c=" + arvadostest.FooCollection + filePath
371         req := newRequest("GET", "http://keep-web.example"+reqPath)
372         coll := newCollection(arvadostest.FooCollection)
373         logEvent := newFileEventLog(s.handler, req, filePath, coll, nil, "")
374         c.Assert(logEvent, check.NotNil)
375         c.Check(logEvent.asFields(), check.DeepEquals, logrus.Fields{
376                 "user_uuid":            s.handler.Cluster.ClusterID + "-tpzed-anonymouspublic",
377                 "collection_uuid":      arvadostest.FooCollection,
378                 "collection_file_path": filePath,
379                 "portable_data_hash":   arvadostest.FooCollectionPDH,
380         })
381 }
382
383 func (s *UnitSuite) TestLogGETPDHAsFields(c *check.C) {
384         filePath := "/Foo"
385         reqPath := "/c=" + arvadostest.FooCollectionPDH + filePath
386         req := newRequest("GET", "http://keep-web.example"+reqPath)
387         coll := newCollection(arvadostest.FooCollectionPDH)
388         user := &arvados.User{
389                 UUID:     arvadostest.ActiveUserUUID,
390                 FullName: "Active User",
391         }
392         logEvent := newFileEventLog(s.handler, req, filePath, coll, user, "")
393         c.Assert(logEvent, check.NotNil)
394         c.Check(logEvent.asFields(), check.DeepEquals, logrus.Fields{
395                 "user_uuid":            arvadostest.ActiveUserUUID,
396                 "user_full_name":       "Active User",
397                 "collection_uuid":      "",
398                 "collection_file_path": filePath,
399                 "portable_data_hash":   arvadostest.FooCollectionPDH,
400         })
401 }
402
403 func (s *UnitSuite) TestLogUploadAsFields(c *check.C) {
404         coll := newCollection(arvadostest.FooCollection)
405         user := &arvados.User{
406                 UUID:     arvadostest.ActiveUserUUID,
407                 FullName: "Active User",
408         }
409         for _, method := range []string{"POST", "PUT"} {
410                 filePath := "/" + method + "File"
411                 reqPath := "/c=" + arvadostest.FooCollection + filePath
412                 req := newRequest(method, "http://keep-web.example"+reqPath)
413                 logEvent := newFileEventLog(s.handler, req, filePath, coll, user, "")
414                 if !c.Check(logEvent, check.NotNil) {
415                         continue
416                 }
417                 c.Check(logEvent.asFields(), check.DeepEquals, logrus.Fields{
418                         "user_uuid":            arvadostest.ActiveUserUUID,
419                         "user_full_name":       "Active User",
420                         "collection_uuid":      arvadostest.FooCollection,
421                         "collection_file_path": filePath,
422                 })
423         }
424 }
425
426 func (s *UnitSuite) TestCORSPreflight(c *check.C) {
427         h := s.handler
428         u := mustParseURL("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo")
429         req := &http.Request{
430                 Method:     "OPTIONS",
431                 Host:       u.Host,
432                 URL:        u,
433                 RequestURI: u.RequestURI(),
434                 Header: http.Header{
435                         "Origin":                        {"https://workbench.example"},
436                         "Access-Control-Request-Method": {"POST"},
437                 },
438         }
439
440         // Check preflight for an allowed request
441         resp := httptest.NewRecorder()
442         h.ServeHTTP(resp, req)
443         c.Check(resp.Code, check.Equals, http.StatusOK)
444         c.Check(resp.Body.String(), check.Equals, "")
445         c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
446         c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Equals, "COPY, DELETE, GET, LOCK, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PROPPATCH, PUT, RMCOL, UNLOCK")
447         c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Equals, "Authorization, Content-Type, Range, Depth, Destination, If, Lock-Token, Overwrite, Timeout, Cache-Control")
448
449         // Check preflight for a disallowed request
450         resp = httptest.NewRecorder()
451         req.Header.Set("Access-Control-Request-Method", "MAKE-COFFEE")
452         h.ServeHTTP(resp, req)
453         c.Check(resp.Body.String(), check.Equals, "")
454         c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed)
455 }
456
457 func (s *UnitSuite) TestWebdavPrefixAndSource(c *check.C) {
458         for _, trial := range []struct {
459                 method   string
460                 path     string
461                 prefix   string
462                 source   string
463                 notFound bool
464                 seeOther bool
465         }{
466                 {
467                         method: "PROPFIND",
468                         path:   "/",
469                 },
470                 {
471                         method: "PROPFIND",
472                         path:   "/dir1",
473                 },
474                 {
475                         method: "PROPFIND",
476                         path:   "/dir1/",
477                 },
478                 {
479                         method: "PROPFIND",
480                         path:   "/dir1/foo",
481                         prefix: "/dir1",
482                         source: "/dir1",
483                 },
484                 {
485                         method: "PROPFIND",
486                         path:   "/prefix/dir1/foo",
487                         prefix: "/prefix/",
488                         source: "",
489                 },
490                 {
491                         method: "PROPFIND",
492                         path:   "/prefix/dir1/foo",
493                         prefix: "/prefix",
494                         source: "",
495                 },
496                 {
497                         method: "PROPFIND",
498                         path:   "/prefix/dir1/foo",
499                         prefix: "/prefix/",
500                         source: "/",
501                 },
502                 {
503                         method: "PROPFIND",
504                         path:   "/prefix/foo",
505                         prefix: "/prefix/",
506                         source: "/dir1/",
507                 },
508                 {
509                         method: "GET",
510                         path:   "/prefix/foo",
511                         prefix: "/prefix/",
512                         source: "/dir1/",
513                 },
514                 {
515                         method: "PROPFIND",
516                         path:   "/prefix/",
517                         prefix: "/prefix",
518                         source: "/dir1",
519                 },
520                 {
521                         method: "PROPFIND",
522                         path:   "/prefix",
523                         prefix: "/prefix",
524                         source: "/dir1/",
525                 },
526                 {
527                         method:   "GET",
528                         path:     "/prefix",
529                         prefix:   "/prefix",
530                         source:   "/dir1",
531                         seeOther: true,
532                 },
533                 {
534                         method:   "PROPFIND",
535                         path:     "/dir1/foo",
536                         prefix:   "",
537                         source:   "/dir1",
538                         notFound: true,
539                 },
540         } {
541                 c.Logf("trial %+v", trial)
542                 u := mustParseURL("http://" + arvadostest.FooBarDirCollection + ".keep-web.example" + trial.path)
543                 req := &http.Request{
544                         Method:     trial.method,
545                         Host:       u.Host,
546                         URL:        u,
547                         RequestURI: u.RequestURI(),
548                         Header: http.Header{
549                                 "Authorization":   {"Bearer " + arvadostest.ActiveTokenV2},
550                                 "X-Webdav-Prefix": {trial.prefix},
551                                 "X-Webdav-Source": {trial.source},
552                         },
553                         Body: ioutil.NopCloser(bytes.NewReader(nil)),
554                 }
555
556                 resp := httptest.NewRecorder()
557                 s.handler.ServeHTTP(resp, req)
558                 if trial.notFound {
559                         c.Check(resp.Code, check.Equals, http.StatusNotFound)
560                 } else if trial.method == "PROPFIND" {
561                         c.Check(resp.Code, check.Equals, http.StatusMultiStatus)
562                         c.Check(resp.Body.String(), check.Matches, `(?ms).*>\n?$`)
563                 } else if trial.seeOther {
564                         c.Check(resp.Code, check.Equals, http.StatusSeeOther)
565                 } else {
566                         c.Check(resp.Code, check.Equals, http.StatusOK)
567                 }
568         }
569 }
570
571 func (s *UnitSuite) TestEmptyResponse(c *check.C) {
572         // Ensure we start with an empty cache
573         defer os.Setenv("HOME", os.Getenv("HOME"))
574         os.Setenv("HOME", c.MkDir())
575         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(0)
576
577         for _, trial := range []struct {
578                 dataExists    bool
579                 sendIMSHeader bool
580                 expectStatus  int
581                 logRegexp     string
582         }{
583                 // If we return no content due to a Keep read error,
584                 // we should emit a log message.
585                 {false, false, http.StatusOK, `(?ms).*only wrote 0 bytes.*`},
586
587                 // If we return no content because the client sent an
588                 // If-Modified-Since header, our response should be
589                 // 304.  We still expect a "File download" log since it
590                 // counts as a file access for auditing.
591                 {true, true, http.StatusNotModified, `(?ms).*msg="File download".*`},
592         } {
593                 c.Logf("trial: %+v", trial)
594                 arvadostest.StartKeep(2, true)
595                 if trial.dataExists {
596                         arv, err := arvadosclient.MakeArvadosClient()
597                         c.Assert(err, check.IsNil)
598                         arv.ApiToken = arvadostest.ActiveToken
599                         kc, err := keepclient.MakeKeepClient(arv)
600                         c.Assert(err, check.IsNil)
601                         _, _, err = kc.PutB([]byte("foo"))
602                         c.Assert(err, check.IsNil)
603                 }
604
605                 u := mustParseURL("http://" + arvadostest.FooCollection + ".keep-web.example/foo")
606                 req := &http.Request{
607                         Method:     "GET",
608                         Host:       u.Host,
609                         URL:        u,
610                         RequestURI: u.RequestURI(),
611                         Header: http.Header{
612                                 "Authorization": {"Bearer " + arvadostest.ActiveToken},
613                         },
614                 }
615                 if trial.sendIMSHeader {
616                         req.Header.Set("If-Modified-Since", strings.Replace(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT", -1))
617                 }
618
619                 var logbuf bytes.Buffer
620                 logger := logrus.New()
621                 logger.Out = &logbuf
622                 req = req.WithContext(ctxlog.Context(context.Background(), logger))
623
624                 resp := httptest.NewRecorder()
625                 s.handler.ServeHTTP(resp, req)
626                 c.Check(resp.Code, check.Equals, trial.expectStatus)
627                 c.Check(resp.Body.String(), check.Equals, "")
628
629                 c.Log(logbuf.String())
630                 c.Check(logbuf.String(), check.Matches, trial.logRegexp)
631         }
632 }
633
634 func (s *UnitSuite) TestInvalidUUID(c *check.C) {
635         bogusID := strings.Replace(arvadostest.FooCollectionPDH, "+", "-", 1) + "-"
636         token := arvadostest.ActiveToken
637         for _, trial := range []string{
638                 "http://keep-web/c=" + bogusID + "/foo",
639                 "http://keep-web/c=" + bogusID + "/t=" + token + "/foo",
640                 "http://keep-web/collections/download/" + bogusID + "/" + token + "/foo",
641                 "http://keep-web/collections/" + bogusID + "/foo",
642                 "http://" + bogusID + ".keep-web/" + bogusID + "/foo",
643                 "http://" + bogusID + ".keep-web/t=" + token + "/" + bogusID + "/foo",
644         } {
645                 c.Log(trial)
646                 u := mustParseURL(trial)
647                 req := &http.Request{
648                         Method:     "GET",
649                         Host:       u.Host,
650                         URL:        u,
651                         RequestURI: u.RequestURI(),
652                 }
653                 resp := httptest.NewRecorder()
654                 s.cluster.Users.AnonymousUserToken = arvadostest.AnonymousToken
655                 s.handler.ServeHTTP(resp, req)
656                 c.Check(resp.Code, check.Equals, http.StatusNotFound)
657         }
658 }
659
660 func mustParseURL(s string) *url.URL {
661         r, err := url.Parse(s)
662         if err != nil {
663                 panic("parse URL: " + s)
664         }
665         return r
666 }
667
668 func (s *IntegrationSuite) TestVhost404(c *check.C) {
669         for _, testURL := range []string{
670                 arvadostest.NonexistentCollection + ".example.com/theperthcountyconspiracy",
671                 arvadostest.NonexistentCollection + ".example.com/t=" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
672         } {
673                 resp := httptest.NewRecorder()
674                 u := mustParseURL(testURL)
675                 req := &http.Request{
676                         Method:     "GET",
677                         URL:        u,
678                         RequestURI: u.RequestURI(),
679                 }
680                 s.handler.ServeHTTP(resp, req)
681                 c.Check(resp.Code, check.Equals, http.StatusNotFound)
682                 c.Check(resp.Body.String(), check.Equals, notFoundMessage+"\n")
683         }
684 }
685
686 // An authorizer modifies an HTTP request to make use of the given
687 // token -- by adding it to a header, cookie, query param, or whatever
688 // -- and returns the HTTP status code we should expect from keep-web if
689 // the token is invalid.
690 type authorizer func(*http.Request, string) int
691
692 // We still need to accept "OAuth2 ..." as equivalent to "Bearer ..."
693 // for compatibility with older clients, including SDKs before 3.0.
694 func (s *IntegrationSuite) TestVhostViaAuthzHeaderOAuth2(c *check.C) {
695         s.doVhostRequests(c, authzViaAuthzHeaderOAuth2)
696 }
697 func authzViaAuthzHeaderOAuth2(r *http.Request, tok string) int {
698         r.Header.Add("Authorization", "OAuth2 "+tok)
699         return http.StatusUnauthorized
700 }
701
702 func (s *IntegrationSuite) TestVhostViaAuthzHeaderBearer(c *check.C) {
703         s.doVhostRequests(c, authzViaAuthzHeaderBearer)
704 }
705 func authzViaAuthzHeaderBearer(r *http.Request, tok string) int {
706         r.Header.Add("Authorization", "Bearer "+tok)
707         return http.StatusUnauthorized
708 }
709
710 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
711         s.doVhostRequests(c, authzViaCookieValue)
712 }
713 func authzViaCookieValue(r *http.Request, tok string) int {
714         r.AddCookie(&http.Cookie{
715                 Name:  "arvados_api_token",
716                 Value: auth.EncodeTokenCookie([]byte(tok)),
717         })
718         return http.StatusUnauthorized
719 }
720
721 func (s *IntegrationSuite) TestVhostViaHTTPBasicAuth(c *check.C) {
722         s.doVhostRequests(c, authzViaHTTPBasicAuth)
723 }
724 func authzViaHTTPBasicAuth(r *http.Request, tok string) int {
725         r.AddCookie(&http.Cookie{
726                 Name:  "arvados_api_token",
727                 Value: auth.EncodeTokenCookie([]byte(tok)),
728         })
729         return http.StatusUnauthorized
730 }
731
732 func (s *IntegrationSuite) TestVhostViaHTTPBasicAuthWithExtraSpaceChars(c *check.C) {
733         s.doVhostRequests(c, func(r *http.Request, tok string) int {
734                 r.AddCookie(&http.Cookie{
735                         Name:  "arvados_api_token",
736                         Value: auth.EncodeTokenCookie([]byte(" " + tok + "\n")),
737                 })
738                 return http.StatusUnauthorized
739         })
740 }
741
742 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
743         s.doVhostRequests(c, authzViaPath)
744 }
745 func authzViaPath(r *http.Request, tok string) int {
746         r.URL.Path = "/t=" + tok + r.URL.Path
747         return http.StatusNotFound
748 }
749
750 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
751         s.doVhostRequests(c, authzViaQueryString)
752 }
753 func authzViaQueryString(r *http.Request, tok string) int {
754         r.URL.RawQuery = "api_token=" + tok
755         return http.StatusUnauthorized
756 }
757
758 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
759         s.doVhostRequests(c, authzViaPOST)
760 }
761 func authzViaPOST(r *http.Request, tok string) int {
762         r.Method = "POST"
763         r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
764         r.Body = ioutil.NopCloser(strings.NewReader(
765                 url.Values{"api_token": {tok}}.Encode()))
766         return http.StatusUnauthorized
767 }
768
769 func (s *IntegrationSuite) TestVhostViaXHRPOST(c *check.C) {
770         s.doVhostRequests(c, authzViaPOST)
771 }
772 func authzViaXHRPOST(r *http.Request, tok string) int {
773         r.Method = "POST"
774         r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
775         r.Header.Add("Origin", "https://origin.example")
776         r.Body = ioutil.NopCloser(strings.NewReader(
777                 url.Values{
778                         "api_token":   {tok},
779                         "disposition": {"attachment"},
780                 }.Encode()))
781         return http.StatusUnauthorized
782 }
783
784 // Try some combinations of {url, token} using the given authorization
785 // mechanism, and verify the result is correct.
786 func (s *IntegrationSuite) doVhostRequests(c *check.C, authz authorizer) {
787         for _, hostPath := range []string{
788                 arvadostest.FooCollection + ".example.com/foo",
789                 arvadostest.FooCollection + "--collections.example.com/foo",
790                 arvadostest.FooCollection + "--collections.example.com/_/foo",
791                 arvadostest.FooCollectionPDH + ".example.com/foo",
792                 strings.Replace(arvadostest.FooCollectionPDH, "+", "-", -1) + "--collections.example.com/foo",
793                 arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
794         } {
795                 c.Log("doRequests: ", hostPath)
796                 s.doVhostRequestsWithHostPath(c, authz, hostPath)
797         }
798 }
799
800 func (s *IntegrationSuite) doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
801         for _, tok := range []string{
802                 arvadostest.ActiveToken,
803                 arvadostest.ActiveToken[:15],
804                 arvadostest.SpectatorToken,
805                 "bogus",
806                 "",
807         } {
808                 u := mustParseURL("http://" + hostPath)
809                 req := &http.Request{
810                         Method:     "GET",
811                         Host:       u.Host,
812                         URL:        u,
813                         RequestURI: u.RequestURI(),
814                         Header:     http.Header{},
815                 }
816                 failCode := authz(req, tok)
817                 req, resp := s.doReq(req)
818                 code, body := resp.Code, resp.Body.String()
819
820                 // If the initial request had a (non-empty) token
821                 // showing in the query string, we should have been
822                 // redirected in order to hide it in a cookie.
823                 c.Check(req.URL.String(), check.Not(check.Matches), `.*api_token=.+`)
824
825                 if tok == arvadostest.ActiveToken {
826                         c.Check(code, check.Equals, http.StatusOK)
827                         c.Check(body, check.Equals, "foo")
828                 } else {
829                         c.Check(code >= 400, check.Equals, true)
830                         c.Check(code < 500, check.Equals, true)
831                         if tok == arvadostest.SpectatorToken {
832                                 // Valid token never offers to retry
833                                 // with different credentials.
834                                 c.Check(code, check.Equals, http.StatusNotFound)
835                         } else {
836                                 // Invalid token can ask to retry
837                                 // depending on the authz method.
838                                 c.Check(code, check.Equals, failCode)
839                         }
840                         if code == 404 {
841                                 c.Check(body, check.Equals, notFoundMessage+"\n")
842                         } else {
843                                 c.Check(body, check.Equals, unauthorizedMessage+"\n")
844                         }
845                 }
846         }
847 }
848
849 func (s *IntegrationSuite) TestVhostPortMatch(c *check.C) {
850         for _, host := range []string{"download.example.com", "DOWNLOAD.EXAMPLE.COM"} {
851                 for _, port := range []string{"80", "443", "8000"} {
852                         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = fmt.Sprintf("download.example.com:%v", port)
853                         u := mustParseURL(fmt.Sprintf("http://%v/by_id/%v/foo", host, arvadostest.FooCollection))
854                         req := &http.Request{
855                                 Method:     "GET",
856                                 Host:       u.Host,
857                                 URL:        u,
858                                 RequestURI: u.RequestURI(),
859                                 Header:     http.Header{"Authorization": []string{"Bearer " + arvadostest.ActiveToken}},
860                         }
861                         req, resp := s.doReq(req)
862                         code, _ := resp.Code, resp.Body.String()
863
864                         if port == "8000" {
865                                 c.Check(code, check.Equals, 401)
866                         } else {
867                                 c.Check(code, check.Equals, 200)
868                         }
869                 }
870         }
871 }
872
873 func (s *IntegrationSuite) do(method string, urlstring string, token string, hdr http.Header) (*http.Request, *httptest.ResponseRecorder) {
874         u := mustParseURL(urlstring)
875         if hdr == nil && token != "" {
876                 hdr = http.Header{"Authorization": {"Bearer " + token}}
877         } else if hdr == nil {
878                 hdr = http.Header{}
879         } else if token != "" {
880                 panic("must not pass both token and hdr")
881         }
882         return s.doReq(&http.Request{
883                 Method:     method,
884                 Host:       u.Host,
885                 URL:        u,
886                 RequestURI: u.RequestURI(),
887                 Header:     hdr,
888         })
889 }
890
891 func (s *IntegrationSuite) doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
892         resp := httptest.NewRecorder()
893         s.handler.ServeHTTP(resp, req)
894         if resp.Code != http.StatusSeeOther {
895                 return req, resp
896         }
897         cookies := (&http.Response{Header: resp.Header()}).Cookies()
898         u, _ := req.URL.Parse(resp.Header().Get("Location"))
899         req = &http.Request{
900                 Method:     "GET",
901                 Host:       u.Host,
902                 URL:        u,
903                 RequestURI: u.RequestURI(),
904                 Header:     http.Header{},
905         }
906         for _, c := range cookies {
907                 req.AddCookie(c)
908         }
909         return s.doReq(req)
910 }
911
912 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
913         s.testVhostRedirectTokenToCookie(c, "GET",
914                 arvadostest.FooCollection+".example.com/foo",
915                 "?api_token="+arvadostest.ActiveToken,
916                 nil,
917                 "",
918                 http.StatusOK,
919                 "foo",
920         )
921 }
922
923 func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) {
924         s.testVhostRedirectTokenToCookie(c, "GET",
925                 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo",
926                 "",
927                 nil,
928                 "",
929                 http.StatusOK,
930                 "foo",
931         )
932 }
933
934 func (s *IntegrationSuite) TestCollectionSharingToken(c *check.C) {
935         s.testVhostRedirectTokenToCookie(c, "GET",
936                 "example.com/c="+arvadostest.FooFileCollectionUUID+"/t="+arvadostest.FooFileCollectionSharingToken+"/foo",
937                 "",
938                 nil,
939                 "",
940                 http.StatusOK,
941                 "foo",
942         )
943         // Same valid sharing token, but requesting a different collection
944         s.testVhostRedirectTokenToCookie(c, "GET",
945                 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.FooFileCollectionSharingToken+"/foo",
946                 "",
947                 nil,
948                 "",
949                 http.StatusNotFound,
950                 regexp.QuoteMeta(notFoundMessage+"\n"),
951         )
952 }
953
954 // Bad token in URL is 404 Not Found because it doesn't make sense to
955 // retry the same URL with different authorization.
956 func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
957         s.testVhostRedirectTokenToCookie(c, "GET",
958                 "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo",
959                 "",
960                 nil,
961                 "",
962                 http.StatusNotFound,
963                 regexp.QuoteMeta(notFoundMessage+"\n"),
964         )
965 }
966
967 // Bad token in a cookie (even if it got there via our own
968 // query-string-to-cookie redirect) is, in principle, retryable via
969 // wb2-login-and-redirect flow.
970 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) {
971         // Inline
972         resp := s.testVhostRedirectTokenToCookie(c, "GET",
973                 arvadostest.FooCollection+".example.com/foo",
974                 "?api_token=thisisabogustoken",
975                 http.Header{"Sec-Fetch-Mode": {"navigate"}},
976                 "",
977                 http.StatusSeeOther,
978                 "",
979         )
980         u, err := url.Parse(resp.Header().Get("Location"))
981         c.Assert(err, check.IsNil)
982         c.Logf("redirected to %s", u)
983         c.Check(u.Host, check.Equals, s.handler.Cluster.Services.Workbench2.ExternalURL.Host)
984         c.Check(u.Query().Get("redirectToPreview"), check.Equals, "/c="+arvadostest.FooCollection+"/foo")
985         c.Check(u.Query().Get("redirectToDownload"), check.Equals, "")
986
987         // Download/attachment indicated by ?disposition=attachment
988         resp = s.testVhostRedirectTokenToCookie(c, "GET",
989                 arvadostest.FooCollection+".example.com/foo",
990                 "?api_token=thisisabogustoken&disposition=attachment",
991                 http.Header{"Sec-Fetch-Mode": {"navigate"}},
992                 "",
993                 http.StatusSeeOther,
994                 "",
995         )
996         u, err = url.Parse(resp.Header().Get("Location"))
997         c.Assert(err, check.IsNil)
998         c.Logf("redirected to %s", u)
999         c.Check(u.Host, check.Equals, s.handler.Cluster.Services.Workbench2.ExternalURL.Host)
1000         c.Check(u.Query().Get("redirectToPreview"), check.Equals, "")
1001         c.Check(u.Query().Get("redirectToDownload"), check.Equals, "/c="+arvadostest.FooCollection+"/foo")
1002
1003         // Download/attachment indicated by vhost
1004         resp = s.testVhostRedirectTokenToCookie(c, "GET",
1005                 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host+"/c="+arvadostest.FooCollection+"/foo",
1006                 "?api_token=thisisabogustoken",
1007                 http.Header{"Sec-Fetch-Mode": {"navigate"}},
1008                 "",
1009                 http.StatusSeeOther,
1010                 "",
1011         )
1012         u, err = url.Parse(resp.Header().Get("Location"))
1013         c.Assert(err, check.IsNil)
1014         c.Logf("redirected to %s", u)
1015         c.Check(u.Host, check.Equals, s.handler.Cluster.Services.Workbench2.ExternalURL.Host)
1016         c.Check(u.Query().Get("redirectToPreview"), check.Equals, "")
1017         c.Check(u.Query().Get("redirectToDownload"), check.Equals, "/c="+arvadostest.FooCollection+"/foo")
1018
1019         // Without "Sec-Fetch-Mode: navigate" header, just 401.
1020         s.testVhostRedirectTokenToCookie(c, "GET",
1021                 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host+"/c="+arvadostest.FooCollection+"/foo",
1022                 "?api_token=thisisabogustoken",
1023                 http.Header{"Sec-Fetch-Mode": {"cors"}},
1024                 "",
1025                 http.StatusUnauthorized,
1026                 regexp.QuoteMeta(unauthorizedMessage+"\n"),
1027         )
1028         s.testVhostRedirectTokenToCookie(c, "GET",
1029                 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host+"/c="+arvadostest.FooCollection+"/foo",
1030                 "?api_token=thisisabogustoken",
1031                 nil,
1032                 "",
1033                 http.StatusUnauthorized,
1034                 regexp.QuoteMeta(unauthorizedMessage+"\n"),
1035         )
1036 }
1037
1038 func (s *IntegrationSuite) TestVhostRedirectWithNoCache(c *check.C) {
1039         resp := s.testVhostRedirectTokenToCookie(c, "GET",
1040                 arvadostest.FooCollection+".example.com/foo",
1041                 "?api_token=thisisabogustoken",
1042                 http.Header{
1043                         "Sec-Fetch-Mode": {"navigate"},
1044                         "Cache-Control":  {"no-cache"},
1045                 },
1046                 "",
1047                 http.StatusSeeOther,
1048                 "",
1049         )
1050         u, err := url.Parse(resp.Header().Get("Location"))
1051         c.Assert(err, check.IsNil)
1052         c.Logf("redirected to %s", u)
1053         c.Check(u.Host, check.Equals, s.handler.Cluster.Services.Workbench2.ExternalURL.Host)
1054         c.Check(u.Query().Get("redirectToPreview"), check.Equals, "/c="+arvadostest.FooCollection+"/foo")
1055         c.Check(u.Query().Get("redirectToDownload"), check.Equals, "")
1056 }
1057
1058 func (s *IntegrationSuite) TestNoTokenWorkbench2LoginFlow(c *check.C) {
1059         for _, trial := range []struct {
1060                 anonToken    bool
1061                 cacheControl string
1062         }{
1063                 {},
1064                 {cacheControl: "no-cache"},
1065                 {anonToken: true},
1066                 {anonToken: true, cacheControl: "no-cache"},
1067         } {
1068                 c.Logf("trial: %+v", trial)
1069
1070                 if trial.anonToken {
1071                         s.handler.Cluster.Users.AnonymousUserToken = arvadostest.AnonymousToken
1072                 } else {
1073                         s.handler.Cluster.Users.AnonymousUserToken = ""
1074                 }
1075                 req, err := http.NewRequest("GET", "http://"+arvadostest.FooCollection+".example.com/foo", nil)
1076                 c.Assert(err, check.IsNil)
1077                 req.Header.Set("Sec-Fetch-Mode", "navigate")
1078                 if trial.cacheControl != "" {
1079                         req.Header.Set("Cache-Control", trial.cacheControl)
1080                 }
1081                 resp := httptest.NewRecorder()
1082                 s.handler.ServeHTTP(resp, req)
1083                 c.Check(resp.Code, check.Equals, http.StatusSeeOther)
1084                 u, err := url.Parse(resp.Header().Get("Location"))
1085                 c.Assert(err, check.IsNil)
1086                 c.Logf("redirected to %q", u)
1087                 c.Check(u.Host, check.Equals, s.handler.Cluster.Services.Workbench2.ExternalURL.Host)
1088                 c.Check(u.Query().Get("redirectToPreview"), check.Equals, "/c="+arvadostest.FooCollection+"/foo")
1089                 c.Check(u.Query().Get("redirectToDownload"), check.Equals, "")
1090         }
1091 }
1092
1093 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) {
1094         s.testVhostRedirectTokenToCookie(c, "GET",
1095                 "example.com/c="+arvadostest.FooCollection+"/foo",
1096                 "?api_token="+arvadostest.ActiveToken,
1097                 nil,
1098                 "",
1099                 http.StatusBadRequest,
1100                 regexp.QuoteMeta("cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)\n"),
1101         )
1102 }
1103
1104 // If client requests an attachment by putting ?disposition=attachment
1105 // in the query string, and gets redirected, the redirect target
1106 // should respond with an attachment.
1107 func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check.C) {
1108         resp := s.testVhostRedirectTokenToCookie(c, "GET",
1109                 arvadostest.FooCollection+".example.com/foo",
1110                 "?disposition=attachment&api_token="+arvadostest.ActiveToken,
1111                 nil,
1112                 "",
1113                 http.StatusOK,
1114                 "foo",
1115         )
1116         c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
1117 }
1118
1119 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSiteFS(c *check.C) {
1120         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
1121         resp := s.testVhostRedirectTokenToCookie(c, "GET",
1122                 "download.example.com/by_id/"+arvadostest.FooCollection+"/foo",
1123                 "?api_token="+arvadostest.ActiveToken,
1124                 nil,
1125                 "",
1126                 http.StatusOK,
1127                 "foo",
1128         )
1129         c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
1130 }
1131
1132 func (s *IntegrationSuite) TestPastCollectionVersionFileAccess(c *check.C) {
1133         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
1134         resp := s.testVhostRedirectTokenToCookie(c, "GET",
1135                 "download.example.com/c="+arvadostest.WazVersion1Collection+"/waz",
1136                 "?api_token="+arvadostest.ActiveToken,
1137                 nil,
1138                 "",
1139                 http.StatusOK,
1140                 "waz",
1141         )
1142         c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
1143         resp = s.testVhostRedirectTokenToCookie(c, "GET",
1144                 "download.example.com/by_id/"+arvadostest.WazVersion1Collection+"/waz",
1145                 "?api_token="+arvadostest.ActiveToken,
1146                 nil,
1147                 "",
1148                 http.StatusOK,
1149                 "waz",
1150         )
1151         c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
1152 }
1153
1154 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
1155         s.handler.Cluster.Collections.TrustAllContent = true
1156         s.testVhostRedirectTokenToCookie(c, "GET",
1157                 "example.com/c="+arvadostest.FooCollection+"/foo",
1158                 "?api_token="+arvadostest.ActiveToken,
1159                 nil,
1160                 "",
1161                 http.StatusOK,
1162                 "foo",
1163         )
1164 }
1165
1166 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
1167         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "example.com:1234"
1168
1169         s.testVhostRedirectTokenToCookie(c, "GET",
1170                 "example.com/c="+arvadostest.FooCollection+"/foo",
1171                 "?api_token="+arvadostest.ActiveToken,
1172                 nil,
1173                 "",
1174                 http.StatusBadRequest,
1175                 regexp.QuoteMeta("cannot serve inline content at this URL (possible configuration error; see https://doc.arvados.org/install/install-keep-web.html#dns)\n"),
1176         )
1177
1178         resp := s.testVhostRedirectTokenToCookie(c, "GET",
1179                 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
1180                 "?api_token="+arvadostest.ActiveToken,
1181                 nil,
1182                 "",
1183                 http.StatusOK,
1184                 "foo",
1185         )
1186         c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
1187 }
1188
1189 func (s *IntegrationSuite) TestVhostRedirectMultipleTokens(c *check.C) {
1190         baseUrl := arvadostest.FooCollection + ".example.com/foo"
1191         query := url.Values{}
1192
1193         // The intent of these tests is to check that requests are redirected
1194         // correctly in the presence of multiple API tokens. The exact response
1195         // codes and content are not closely considered: they're just how
1196         // keep-web responded when we made the smallest possible fix. Changing
1197         // those responses may be okay, but you should still test all these
1198         // different cases and the associated redirect logic.
1199         query["api_token"] = []string{arvadostest.ActiveToken, arvadostest.AnonymousToken}
1200         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
1201         query["api_token"] = []string{arvadostest.ActiveToken, arvadostest.AnonymousToken, ""}
1202         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
1203         query["api_token"] = []string{arvadostest.ActiveToken, "", arvadostest.AnonymousToken}
1204         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
1205         query["api_token"] = []string{"", arvadostest.ActiveToken}
1206         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusOK, "foo")
1207
1208         expectContent := regexp.QuoteMeta(unauthorizedMessage + "\n")
1209         query["api_token"] = []string{arvadostest.AnonymousToken, "invalidtoo"}
1210         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusUnauthorized, expectContent)
1211         query["api_token"] = []string{arvadostest.AnonymousToken, ""}
1212         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusUnauthorized, expectContent)
1213         query["api_token"] = []string{"", arvadostest.AnonymousToken}
1214         s.testVhostRedirectTokenToCookie(c, "GET", baseUrl, "?"+query.Encode(), nil, "", http.StatusUnauthorized, expectContent)
1215 }
1216
1217 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
1218         s.testVhostRedirectTokenToCookie(c, "POST",
1219                 arvadostest.FooCollection+".example.com/foo",
1220                 "",
1221                 http.Header{"Content-Type": {"application/x-www-form-urlencoded"}},
1222                 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
1223                 http.StatusOK,
1224                 "foo",
1225         )
1226 }
1227
1228 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
1229         s.testVhostRedirectTokenToCookie(c, "POST",
1230                 arvadostest.FooCollection+".example.com/foo",
1231                 "",
1232                 http.Header{"Content-Type": {"application/x-www-form-urlencoded"}},
1233                 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
1234                 http.StatusNotFound,
1235                 regexp.QuoteMeta(notFoundMessage+"\n"),
1236         )
1237 }
1238
1239 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
1240         s.handler.Cluster.Users.AnonymousUserToken = arvadostest.AnonymousToken
1241         s.testVhostRedirectTokenToCookie(c, "GET",
1242                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
1243                 "",
1244                 nil,
1245                 "",
1246                 http.StatusOK,
1247                 "Hello world\n",
1248         )
1249 }
1250
1251 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
1252         s.handler.Cluster.Users.AnonymousUserToken = "anonymousTokenConfiguredButInvalid"
1253         s.testVhostRedirectTokenToCookie(c, "GET",
1254                 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
1255                 "",
1256                 nil,
1257                 "",
1258                 http.StatusUnauthorized,
1259                 "Authorization tokens are not accepted here: .*\n",
1260         )
1261 }
1262
1263 func (s *IntegrationSuite) TestSpecialCharsInPath(c *check.C) {
1264         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
1265
1266         client := arvados.NewClientFromEnv()
1267         client.AuthToken = arvadostest.ActiveToken
1268         fs, err := (&arvados.Collection{}).FileSystem(client, nil)
1269         c.Assert(err, check.IsNil)
1270         path := `https:\\"odd' path chars`
1271         f, err := fs.OpenFile(path, os.O_CREATE, 0777)
1272         c.Assert(err, check.IsNil)
1273         f.Close()
1274         mtxt, err := fs.MarshalManifest(".")
1275         c.Assert(err, check.IsNil)
1276         var coll arvados.Collection
1277         err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
1278                 "collection": map[string]string{
1279                         "manifest_text": mtxt,
1280                 },
1281         })
1282         c.Assert(err, check.IsNil)
1283
1284         u, _ := url.Parse("http://download.example.com/c=" + coll.UUID + "/")
1285         req := &http.Request{
1286                 Method:     "GET",
1287                 Host:       u.Host,
1288                 URL:        u,
1289                 RequestURI: u.RequestURI(),
1290                 Header: http.Header{
1291                         "Authorization": {"Bearer " + client.AuthToken},
1292                 },
1293         }
1294         resp := httptest.NewRecorder()
1295         s.handler.ServeHTTP(resp, req)
1296         c.Check(resp.Code, check.Equals, http.StatusOK)
1297         doc, err := html.Parse(resp.Body)
1298         c.Assert(err, check.IsNil)
1299         pathHrefMap := getPathHrefMap(doc)
1300         c.Check(pathHrefMap, check.HasLen, 1) // the one leaf added to collection
1301         href, hasPath := pathHrefMap[path]
1302         c.Assert(hasPath, check.Equals, true) // the path is listed
1303         relUrl := mustParseURL(href)
1304         c.Check(relUrl.Path, check.Equals, "./"+path) // href can be decoded back to path
1305 }
1306
1307 func (s *IntegrationSuite) TestForwardSlashSubstitution(c *check.C) {
1308         arv := arvados.NewClientFromEnv()
1309         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
1310         s.handler.Cluster.Collections.ForwardSlashNameSubstitution = "{SOLIDUS}"
1311         name := "foo/bar/baz"
1312         nameShown := strings.Replace(name, "/", "{SOLIDUS}", -1)
1313
1314         client := arvados.NewClientFromEnv()
1315         client.AuthToken = arvadostest.ActiveToken
1316         fs, err := (&arvados.Collection{}).FileSystem(client, nil)
1317         c.Assert(err, check.IsNil)
1318         f, err := fs.OpenFile("filename", os.O_CREATE, 0777)
1319         c.Assert(err, check.IsNil)
1320         f.Close()
1321         mtxt, err := fs.MarshalManifest(".")
1322         c.Assert(err, check.IsNil)
1323         var coll arvados.Collection
1324         err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
1325                 "collection": map[string]string{
1326                         "manifest_text": mtxt,
1327                         "name":          name,
1328                         "owner_uuid":    arvadostest.AProjectUUID,
1329                 },
1330         })
1331         c.Assert(err, check.IsNil)
1332         defer arv.RequestAndDecode(&coll, "DELETE", "arvados/v1/collections/"+coll.UUID, nil, nil)
1333
1334         base := "http://download.example.com/by_id/" + coll.OwnerUUID + "/"
1335         for tryURL, expectedAnchorText := range map[string]string{
1336                 base:                   nameShown + "/",
1337                 base + nameShown + "/": "filename",
1338         } {
1339                 u, _ := url.Parse(tryURL)
1340                 req := &http.Request{
1341                         Method:     "GET",
1342                         Host:       u.Host,
1343                         URL:        u,
1344                         RequestURI: u.RequestURI(),
1345                         Header: http.Header{
1346                                 "Authorization": {"Bearer " + client.AuthToken},
1347                         },
1348                 }
1349                 resp := httptest.NewRecorder()
1350                 s.handler.ServeHTTP(resp, req)
1351                 c.Check(resp.Code, check.Equals, http.StatusOK)
1352                 doc, err := html.Parse(resp.Body)
1353                 c.Assert(err, check.IsNil) // valid HTML
1354                 pathHrefMap := getPathHrefMap(doc)
1355                 href, hasExpected := pathHrefMap[expectedAnchorText]
1356                 c.Assert(hasExpected, check.Equals, true) // has expected anchor text
1357                 c.Assert(href, check.Not(check.Equals), "")
1358                 relUrl := mustParseURL(href)
1359                 c.Check(relUrl.Path, check.Equals, "./"+expectedAnchorText) // decoded href maps back to the anchor text
1360         }
1361 }
1362
1363 // XHRs can't follow redirect-with-cookie so they rely on method=POST
1364 // and disposition=attachment (telling us it's acceptable to respond
1365 // with content instead of a redirect) and an Origin header that gets
1366 // added automatically by the browser (telling us it's desirable to do
1367 // so).
1368 func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
1369         u, _ := url.Parse("http://example.com/c=" + arvadostest.FooCollection + "/foo")
1370         req := &http.Request{
1371                 Method:     "POST",
1372                 Host:       u.Host,
1373                 URL:        u,
1374                 RequestURI: u.RequestURI(),
1375                 Header: http.Header{
1376                         "Origin":       {"https://origin.example"},
1377                         "Content-Type": {"application/x-www-form-urlencoded"},
1378                 },
1379                 Body: ioutil.NopCloser(strings.NewReader(url.Values{
1380                         "api_token":   {arvadostest.ActiveToken},
1381                         "disposition": {"attachment"},
1382                 }.Encode())),
1383         }
1384         resp := httptest.NewRecorder()
1385         s.handler.ServeHTTP(resp, req)
1386         c.Check(resp.Code, check.Equals, http.StatusOK)
1387         c.Check(resp.Body.String(), check.Equals, "foo")
1388         c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
1389
1390         // GET + Origin header is representative of both AJAX GET
1391         // requests and inline images via <IMG crossorigin="anonymous"
1392         // src="...">.
1393         u.RawQuery = "api_token=" + url.QueryEscape(arvadostest.ActiveTokenV2)
1394         req = &http.Request{
1395                 Method:     "GET",
1396                 Host:       u.Host,
1397                 URL:        u,
1398                 RequestURI: u.RequestURI(),
1399                 Header: http.Header{
1400                         "Origin": {"https://origin.example"},
1401                 },
1402         }
1403         resp = httptest.NewRecorder()
1404         s.handler.ServeHTTP(resp, req)
1405         c.Check(resp.Code, check.Equals, http.StatusOK)
1406         c.Check(resp.Body.String(), check.Equals, "foo")
1407         c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
1408 }
1409
1410 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString string, reqHeader http.Header, reqBody string, expectStatus int, matchRespBody string) *httptest.ResponseRecorder {
1411         if reqHeader == nil {
1412                 reqHeader = http.Header{}
1413         }
1414         u, _ := url.Parse(`http://` + hostPath + queryString)
1415         c.Logf("requesting %s", u)
1416         req := &http.Request{
1417                 Method:     method,
1418                 Host:       u.Host,
1419                 URL:        u,
1420                 RequestURI: u.RequestURI(),
1421                 Header:     reqHeader,
1422                 Body:       ioutil.NopCloser(strings.NewReader(reqBody)),
1423         }
1424
1425         resp := httptest.NewRecorder()
1426         defer func() {
1427                 c.Check(resp.Code, check.Equals, expectStatus)
1428                 c.Check(resp.Body.String(), check.Matches, matchRespBody)
1429         }()
1430
1431         s.handler.ServeHTTP(resp, req)
1432         if resp.Code != http.StatusSeeOther {
1433                 attachment, _ := regexp.MatchString(`^attachment(;|$)`, resp.Header().Get("Content-Disposition"))
1434                 // Since we're not redirecting, check that any api_token in the URL is
1435                 // handled safely.
1436                 // If there is no token in the URL, then we're good.
1437                 // Otherwise, if the response code is an error, the body is expected to
1438                 // be static content, and nothing that might maliciously introspect the
1439                 // URL. It's considered safe and allowed.
1440                 // Otherwise, if the response content has attachment disposition,
1441                 // that's considered safe for all the reasons explained in the
1442                 // safeAttachment comment in handler.go.
1443                 c.Check(!u.Query().Has("api_token") || resp.Code >= 400 || attachment, check.Equals, true)
1444                 return resp
1445         }
1446
1447         loc, err := url.Parse(resp.Header().Get("Location"))
1448         c.Assert(err, check.IsNil)
1449         c.Check(loc.Scheme, check.Equals, u.Scheme)
1450         c.Check(loc.Host, check.Equals, u.Host)
1451         c.Check(loc.RawPath, check.Equals, u.RawPath)
1452         // If the response was a redirect, it should never include an API token.
1453         c.Check(loc.Query().Has("api_token"), check.Equals, false)
1454         c.Check(resp.Body.String(), check.Matches, `.*href="http://`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
1455         cookies := (&http.Response{Header: resp.Header()}).Cookies()
1456
1457         c.Logf("following redirect to %s", u)
1458         req = &http.Request{
1459                 Method:     "GET",
1460                 Host:       loc.Host,
1461                 URL:        loc,
1462                 RequestURI: loc.RequestURI(),
1463                 Header:     reqHeader,
1464         }
1465         for _, c := range cookies {
1466                 req.AddCookie(c)
1467         }
1468
1469         resp = httptest.NewRecorder()
1470         s.handler.ServeHTTP(resp, req)
1471
1472         if resp.Code != http.StatusSeeOther {
1473                 c.Check(resp.Header().Get("Location"), check.Equals, "")
1474         }
1475         return resp
1476 }
1477
1478 func (s *IntegrationSuite) TestDirectoryListingWithAnonymousToken(c *check.C) {
1479         s.handler.Cluster.Users.AnonymousUserToken = arvadostest.AnonymousToken
1480         s.testDirectoryListing(c)
1481 }
1482
1483 func (s *IntegrationSuite) TestDirectoryListingWithNoAnonymousToken(c *check.C) {
1484         s.handler.Cluster.Users.AnonymousUserToken = ""
1485         s.testDirectoryListing(c)
1486 }
1487
1488 func (s *IntegrationSuite) testDirectoryListing(c *check.C) {
1489         // The "ownership cycle" test fixtures are reachable from the
1490         // "filter group without filters" group, causing webdav's
1491         // walkfs to recurse indefinitely. Avoid that by deleting one
1492         // of the bogus fixtures.
1493         arv := arvados.NewClientFromEnv()
1494         err := arv.RequestAndDecode(nil, "DELETE", "arvados/v1/groups/zzzzz-j7d0g-cx2al9cqkmsf1hs", nil, nil)
1495         if err != nil {
1496                 c.Assert(err, check.FitsTypeOf, &arvados.TransactionError{})
1497                 c.Check(err.(*arvados.TransactionError).StatusCode, check.Equals, 404)
1498         }
1499
1500         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
1501         authHeader := http.Header{
1502                 "Authorization": {"Bearer " + arvadostest.ActiveToken},
1503         }
1504         for _, trial := range []struct {
1505                 uri      string
1506                 header   http.Header
1507                 expect   []string
1508                 redirect string
1509                 cutDirs  int
1510         }{
1511                 {
1512                         uri:     strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/",
1513                         header:  authHeader,
1514                         expect:  []string{"dir1/foo", "dir1/bar"},
1515                         cutDirs: 0,
1516                 },
1517                 {
1518                         uri:     strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/dir1/",
1519                         header:  authHeader,
1520                         expect:  []string{"foo", "bar"},
1521                         cutDirs: 1,
1522                 },
1523                 {
1524                         // URLs of this form ignore authHeader, and
1525                         // FooAndBarFilesInDirUUID isn't public, so
1526                         // this returns 401.
1527                         uri:    "download.example.com/collections/" + arvadostest.FooAndBarFilesInDirUUID + "/",
1528                         header: authHeader,
1529                         expect: nil,
1530                 },
1531                 {
1532                         uri:     "download.example.com/users/active/foo_file_in_dir/",
1533                         header:  authHeader,
1534                         expect:  []string{"dir1/"},
1535                         cutDirs: 3,
1536                 },
1537                 {
1538                         uri:     "download.example.com/users/active/foo_file_in_dir/dir1/",
1539                         header:  authHeader,
1540                         expect:  []string{"bar"},
1541                         cutDirs: 4,
1542                 },
1543                 {
1544                         uri:     "download.example.com/",
1545                         header:  authHeader,
1546                         expect:  []string{"users/"},
1547                         cutDirs: 0,
1548                 },
1549                 {
1550                         uri:      "download.example.com/users",
1551                         header:   authHeader,
1552                         redirect: "/users/",
1553                         expect:   []string{"active/"},
1554                         cutDirs:  1,
1555                 },
1556                 {
1557                         uri:     "download.example.com/users/",
1558                         header:  authHeader,
1559                         expect:  []string{"active/"},
1560                         cutDirs: 1,
1561                 },
1562                 {
1563                         uri:      "download.example.com/users/active",
1564                         header:   authHeader,
1565                         redirect: "/users/active/",
1566                         expect:   []string{"foo_file_in_dir/"},
1567                         cutDirs:  2,
1568                 },
1569                 {
1570                         uri:     "download.example.com/users/active/",
1571                         header:  authHeader,
1572                         expect:  []string{"foo_file_in_dir/"},
1573                         cutDirs: 2,
1574                 },
1575                 {
1576                         uri:     "collections.example.com/collections/download/" + arvadostest.FooAndBarFilesInDirUUID + "/" + arvadostest.ActiveToken + "/",
1577                         header:  nil,
1578                         expect:  []string{"dir1/foo", "dir1/bar"},
1579                         cutDirs: 4,
1580                 },
1581                 {
1582                         uri:     "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/",
1583                         header:  nil,
1584                         expect:  []string{"dir1/foo", "dir1/bar"},
1585                         cutDirs: 2,
1586                 },
1587                 {
1588                         uri:     "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken,
1589                         header:  nil,
1590                         expect:  []string{"dir1/foo", "dir1/bar"},
1591                         cutDirs: 2,
1592                 },
1593                 {
1594                         uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID,
1595                         header:  authHeader,
1596                         expect:  []string{"dir1/foo", "dir1/bar"},
1597                         cutDirs: 1,
1598                 },
1599                 {
1600                         uri:      "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1",
1601                         header:   authHeader,
1602                         redirect: "/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1/",
1603                         expect:   []string{"foo", "bar"},
1604                         cutDirs:  2,
1605                 },
1606                 {
1607                         uri:     "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/_/dir1/",
1608                         header:  authHeader,
1609                         expect:  []string{"foo", "bar"},
1610                         cutDirs: 3,
1611                 },
1612                 {
1613                         uri:      arvadostest.FooAndBarFilesInDirUUID + ".example.com/dir1?api_token=" + arvadostest.ActiveToken,
1614                         header:   authHeader,
1615                         redirect: "/dir1/",
1616                         expect:   []string{"foo", "bar"},
1617                         cutDirs:  1,
1618                 },
1619                 {
1620                         uri:    "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/theperthcountyconspiracydoesnotexist/",
1621                         header: authHeader,
1622                         expect: nil,
1623                 },
1624                 {
1625                         uri:     "download.example.com/c=" + arvadostest.WazVersion1Collection,
1626                         header:  authHeader,
1627                         expect:  []string{"waz"},
1628                         cutDirs: 1,
1629                 },
1630                 {
1631                         uri:     "download.example.com/by_id/" + arvadostest.WazVersion1Collection,
1632                         header:  authHeader,
1633                         expect:  []string{"waz"},
1634                         cutDirs: 2,
1635                 },
1636                 {
1637                         uri:     "download.example.com/users/active/This filter group/",
1638                         header:  authHeader,
1639                         expect:  []string{"A Subproject/"},
1640                         cutDirs: 3,
1641                 },
1642                 {
1643                         uri:     "download.example.com/users/active/This filter group/A Subproject",
1644                         header:  authHeader,
1645                         expect:  []string{"baz_file/"},
1646                         cutDirs: 4,
1647                 },
1648                 {
1649                         uri:     "download.example.com/by_id/" + arvadostest.AFilterGroupUUID,
1650                         header:  authHeader,
1651                         expect:  []string{"A Subproject/"},
1652                         cutDirs: 2,
1653                 },
1654                 {
1655                         uri:     "download.example.com/by_id/" + arvadostest.AFilterGroupUUID + "/A Subproject",
1656                         header:  authHeader,
1657                         expect:  []string{"baz_file/"},
1658                         cutDirs: 3,
1659                 },
1660         } {
1661                 comment := check.Commentf("HTML: %q redir %q => %q", trial.uri, trial.redirect, trial.expect)
1662                 resp := httptest.NewRecorder()
1663                 u := mustParseURL("//" + trial.uri)
1664                 req := &http.Request{
1665                         Method:     "GET",
1666                         Host:       u.Host,
1667                         URL:        u,
1668                         RequestURI: u.RequestURI(),
1669                         Header:     copyHeader(trial.header),
1670                 }
1671                 s.handler.ServeHTTP(resp, req)
1672                 var cookies []*http.Cookie
1673                 for resp.Code == http.StatusSeeOther {
1674                         u, _ := req.URL.Parse(resp.Header().Get("Location"))
1675                         req = &http.Request{
1676                                 Method:     "GET",
1677                                 Host:       u.Host,
1678                                 URL:        u,
1679                                 RequestURI: u.RequestURI(),
1680                                 Header:     copyHeader(trial.header),
1681                         }
1682                         cookies = append(cookies, (&http.Response{Header: resp.Header()}).Cookies()...)
1683                         for _, c := range cookies {
1684                                 req.AddCookie(c)
1685                         }
1686                         resp = httptest.NewRecorder()
1687                         s.handler.ServeHTTP(resp, req)
1688                 }
1689                 if trial.redirect != "" {
1690                         c.Check(req.URL.Path, check.Equals, trial.redirect, comment)
1691                 }
1692                 if trial.expect == nil {
1693                         c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
1694                 } else {
1695                         c.Check(resp.Code, check.Equals, http.StatusOK, comment)
1696                         listingPageDoc, err := html.Parse(resp.Body)
1697                         c.Check(err, check.IsNil, comment) // valid HTML document
1698                         pathHrefMap := getPathHrefMap(listingPageDoc)
1699                         c.Assert(pathHrefMap, check.Not(check.HasLen), 0, comment)
1700                         for _, e := range trial.expect {
1701                                 href, hasE := pathHrefMap[e]
1702                                 c.Check(hasE, check.Equals, true, comment) // expected path is listed
1703                                 relUrl := mustParseURL(href)
1704                                 c.Check(relUrl.Path, check.Equals, "./"+e, comment) // href can be decoded back to path
1705                         }
1706                         wgetCommand := getWgetExamplePre(listingPageDoc)
1707                         wgetExpected := regexp.MustCompile(`^\$ wget .*--cut-dirs=(\d+) .*'(https?://[^']+)'$`)
1708                         wgetMatchGroups := wgetExpected.FindStringSubmatch(wgetCommand)
1709                         c.Assert(wgetMatchGroups, check.NotNil)                                     // wget command matches
1710                         c.Check(wgetMatchGroups[1], check.Equals, fmt.Sprintf("%d", trial.cutDirs)) // correct level of cut dirs in wget command
1711                         printedUrl := mustParseURL(wgetMatchGroups[2])
1712                         c.Check(printedUrl.Host, check.Equals, req.URL.Host)
1713                         c.Check(printedUrl.Path, check.Equals, req.URL.Path) // URL arg in wget command can be decoded to the right path
1714                 }
1715
1716                 comment = check.Commentf("WebDAV: %q => %q", trial.uri, trial.expect)
1717                 req = &http.Request{
1718                         Method:     "OPTIONS",
1719                         Host:       u.Host,
1720                         URL:        u,
1721                         RequestURI: u.RequestURI(),
1722                         Header:     copyHeader(trial.header),
1723                         Body:       ioutil.NopCloser(&bytes.Buffer{}),
1724                 }
1725                 resp = httptest.NewRecorder()
1726                 s.handler.ServeHTTP(resp, req)
1727                 if trial.expect == nil {
1728                         c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
1729                 } else {
1730                         c.Check(resp.Code, check.Equals, http.StatusOK, comment)
1731                 }
1732
1733                 req = &http.Request{
1734                         Method:     "PROPFIND",
1735                         Host:       u.Host,
1736                         URL:        u,
1737                         RequestURI: u.RequestURI(),
1738                         Header:     copyHeader(trial.header),
1739                         Body:       ioutil.NopCloser(&bytes.Buffer{}),
1740                 }
1741                 resp = httptest.NewRecorder()
1742                 s.handler.ServeHTTP(resp, req)
1743                 // This check avoids logging a big XML document in the
1744                 // event webdav throws a 500 error after sending
1745                 // headers for a 207.
1746                 if !c.Check(strings.HasSuffix(resp.Body.String(), "Internal Server Error"), check.Equals, false) {
1747                         continue
1748                 }
1749                 if trial.expect == nil {
1750                         c.Check(resp.Code, check.Equals, http.StatusUnauthorized, comment)
1751                 } else {
1752                         c.Check(resp.Code, check.Equals, http.StatusMultiStatus, comment)
1753                         for _, e := range trial.expect {
1754                                 if strings.HasSuffix(e, "/") {
1755                                         e = filepath.Join(u.Path, e) + "/"
1756                                 } else {
1757                                         e = filepath.Join(u.Path, e)
1758                                 }
1759                                 e = strings.Replace(e, " ", "%20", -1)
1760                                 c.Check(resp.Body.String(), check.Matches, `(?ms).*<D:href>`+e+`</D:href>.*`, comment)
1761                         }
1762                 }
1763         }
1764 }
1765
1766 // Shallow-traverse the HTML document, gathering the nodes satisfying the
1767 // predicate function in the output slice. If a node matches the predicate,
1768 // none of its children will be visited.
1769 func getNodes(document *html.Node, predicate func(*html.Node) bool) []*html.Node {
1770         var acc []*html.Node
1771         var traverse func(*html.Node, []*html.Node) []*html.Node
1772         traverse = func(root *html.Node, sofar []*html.Node) []*html.Node {
1773                 if root == nil {
1774                         return sofar
1775                 }
1776                 if predicate(root) {
1777                         return append(sofar, root)
1778                 }
1779                 for cur := root.FirstChild; cur != nil; cur = cur.NextSibling {
1780                         sofar = traverse(cur, sofar)
1781                 }
1782                 return sofar
1783         }
1784         return traverse(document, acc)
1785 }
1786
1787 // Returns true if a node has the attribute targetAttr with the given value
1788 func matchesAttributeValue(node *html.Node, targetAttr string, value string) bool {
1789         for _, attr := range node.Attr {
1790                 if attr.Key == targetAttr && attr.Val == value {
1791                         return true
1792                 }
1793         }
1794         return false
1795 }
1796
1797 // Concatenate the content of text-node children of node; only direct
1798 // children are visited, and any non-text children are skipped.
1799 func getNodeText(node *html.Node) string {
1800         var recv strings.Builder
1801         for c := node.FirstChild; c != nil; c = c.NextSibling {
1802                 if c.Type == html.TextNode {
1803                         recv.WriteString(c.Data)
1804                 }
1805         }
1806         return recv.String()
1807 }
1808
1809 // Returns a map from the directory listing item string (a path) to the href
1810 // value of its <a> tag (an encoded relative URL)
1811 func getPathHrefMap(document *html.Node) map[string]string {
1812         isItemATag := func(node *html.Node) bool {
1813                 return node.Type == html.ElementNode && node.Data == "a" && matchesAttributeValue(node, "class", "item")
1814         }
1815         aTags := getNodes(document, isItemATag)
1816         output := make(map[string]string)
1817         for _, elem := range aTags {
1818                 textContent := getNodeText(elem)
1819                 for _, attr := range elem.Attr {
1820                         if attr.Key == "href" {
1821                                 output[textContent] = attr.Val
1822                                 break
1823                         }
1824                 }
1825         }
1826         return output
1827 }
1828
1829 func getWgetExamplePre(document *html.Node) string {
1830         isWgetPre := func(node *html.Node) bool {
1831                 return node.Type == html.ElementNode && matchesAttributeValue(node, "id", "wget-example")
1832         }
1833         elements := getNodes(document, isWgetPre)
1834         if len(elements) != 1 {
1835                 return ""
1836         }
1837         return getNodeText(elements[0])
1838 }
1839
1840 func (s *IntegrationSuite) TestDeleteLastFile(c *check.C) {
1841         arv := arvados.NewClientFromEnv()
1842         var newCollection arvados.Collection
1843         err := arv.RequestAndDecode(&newCollection, "POST", "arvados/v1/collections", nil, map[string]interface{}{
1844                 "collection": map[string]string{
1845                         "owner_uuid":    arvadostest.ActiveUserUUID,
1846                         "manifest_text": ". acbd18db4cc2f85cedef654fccc4a4d8+3 0:3:foo.txt 0:3:bar.txt\n",
1847                         "name":          "keep-web test collection",
1848                 },
1849                 "ensure_unique_name": true,
1850         })
1851         c.Assert(err, check.IsNil)
1852         defer arv.RequestAndDecode(&newCollection, "DELETE", "arvados/v1/collections/"+newCollection.UUID, nil, nil)
1853
1854         var updated arvados.Collection
1855         for _, fnm := range []string{"foo.txt", "bar.txt"} {
1856                 s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "example.com"
1857                 u, _ := url.Parse("http://example.com/c=" + newCollection.UUID + "/" + fnm)
1858                 req := &http.Request{
1859                         Method:     "DELETE",
1860                         Host:       u.Host,
1861                         URL:        u,
1862                         RequestURI: u.RequestURI(),
1863                         Header: http.Header{
1864                                 "Authorization": {"Bearer " + arvadostest.ActiveToken},
1865                         },
1866                 }
1867                 resp := httptest.NewRecorder()
1868                 s.handler.ServeHTTP(resp, req)
1869                 c.Check(resp.Code, check.Equals, http.StatusNoContent)
1870
1871                 updated = arvados.Collection{}
1872                 err = arv.RequestAndDecode(&updated, "GET", "arvados/v1/collections/"+newCollection.UUID, nil, nil)
1873                 c.Check(err, check.IsNil)
1874                 c.Check(updated.ManifestText, check.Not(check.Matches), `(?ms).*\Q`+fnm+`\E.*`)
1875                 c.Logf("updated manifest_text %q", updated.ManifestText)
1876         }
1877         c.Check(updated.ManifestText, check.Equals, "")
1878 }
1879
1880 func (s *IntegrationSuite) TestFileContentType(c *check.C) {
1881         s.handler.Cluster.Services.WebDAVDownload.ExternalURL.Host = "download.example.com"
1882
1883         client := arvados.NewClientFromEnv()
1884         client.AuthToken = arvadostest.ActiveToken
1885         arv, err := arvadosclient.New(client)
1886         c.Assert(err, check.Equals, nil)
1887         kc, err := keepclient.MakeKeepClient(arv)
1888         c.Assert(err, check.Equals, nil)
1889
1890         fs, err := (&arvados.Collection{}).FileSystem(client, kc)
1891         c.Assert(err, check.IsNil)
1892
1893         trials := []struct {
1894                 filename    string
1895                 content     string
1896                 contentType string
1897         }{
1898                 {"picture.txt", "BMX bikes are small this year\n", "text/plain; charset=utf-8"},
1899                 {"picture.bmp", "BMX bikes are small this year\n", "image/(x-ms-)?bmp"},
1900                 {"picture.jpg", "BMX bikes are small this year\n", "image/jpeg"},
1901                 {"picture1", "BMX bikes are small this year\n", "image/bmp"},            // content sniff; "BM" is the magic signature for .bmp
1902                 {"picture2", "Cars are small this year\n", "text/plain; charset=utf-8"}, // content sniff
1903         }
1904         for _, trial := range trials {
1905                 f, err := fs.OpenFile(trial.filename, os.O_CREATE|os.O_WRONLY, 0777)
1906                 c.Assert(err, check.IsNil)
1907                 _, err = f.Write([]byte(trial.content))
1908                 c.Assert(err, check.IsNil)
1909                 c.Assert(f.Close(), check.IsNil)
1910         }
1911         mtxt, err := fs.MarshalManifest(".")
1912         c.Assert(err, check.IsNil)
1913         var coll arvados.Collection
1914         err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, map[string]interface{}{
1915                 "collection": map[string]string{
1916                         "manifest_text": mtxt,
1917                 },
1918         })
1919         c.Assert(err, check.IsNil)
1920
1921         for _, trial := range trials {
1922                 u, _ := url.Parse("http://download.example.com/by_id/" + coll.UUID + "/" + trial.filename)
1923                 req := &http.Request{
1924                         Method:     "GET",
1925                         Host:       u.Host,
1926                         URL:        u,
1927                         RequestURI: u.RequestURI(),
1928                         Header: http.Header{
1929                                 "Authorization": {"Bearer " + client.AuthToken},
1930                         },
1931                 }
1932                 resp := httptest.NewRecorder()
1933                 s.handler.ServeHTTP(resp, req)
1934                 c.Check(resp.Code, check.Equals, http.StatusOK)
1935                 c.Check(resp.Header().Get("Content-Type"), check.Matches, trial.contentType)
1936                 c.Check(resp.Body.String(), check.Equals, trial.content)
1937         }
1938 }
1939
1940 func (s *IntegrationSuite) TestCacheSize(c *check.C) {
1941         req, err := http.NewRequest("GET", "http://"+arvadostest.FooCollection+".example.com/foo", nil)
1942         req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveTokenV2)
1943         c.Assert(err, check.IsNil)
1944         resp := httptest.NewRecorder()
1945         s.handler.ServeHTTP(resp, req)
1946         c.Assert(resp.Code, check.Equals, http.StatusOK)
1947         c.Check(s.handler.Cache.sessions[arvadostest.ActiveTokenV2].client.DiskCacheSize.Percent(), check.Equals, int64(10))
1948 }
1949
1950 // Writing to a collection shouldn't affect its entry in the
1951 // PDH-to-manifest cache.
1952 func (s *IntegrationSuite) TestCacheWriteCollectionSamePDH(c *check.C) {
1953         arv, err := arvadosclient.MakeArvadosClient()
1954         c.Assert(err, check.Equals, nil)
1955         arv.ApiToken = arvadostest.ActiveToken
1956
1957         u := mustParseURL("http://x.example/testfile")
1958         req := &http.Request{
1959                 Method:     "GET",
1960                 Host:       u.Host,
1961                 URL:        u,
1962                 RequestURI: u.RequestURI(),
1963                 Header:     http.Header{"Authorization": {"Bearer " + arv.ApiToken}},
1964         }
1965
1966         checkWithID := func(id string, status int) {
1967                 req.URL.Host = strings.Replace(id, "+", "-", -1) + ".example"
1968                 req.Host = req.URL.Host
1969                 resp := httptest.NewRecorder()
1970                 s.handler.ServeHTTP(resp, req)
1971                 c.Check(resp.Code, check.Equals, status)
1972         }
1973
1974         var colls [2]arvados.Collection
1975         for i := range colls {
1976                 err := arv.Create("collections",
1977                         map[string]interface{}{
1978                                 "ensure_unique_name": true,
1979                                 "collection": map[string]interface{}{
1980                                         "name": "test collection",
1981                                 },
1982                         }, &colls[i])
1983                 c.Assert(err, check.Equals, nil)
1984         }
1985
1986         // Populate cache with empty collection
1987         checkWithID(colls[0].PortableDataHash, http.StatusNotFound)
1988
1989         // write a file to colls[0]
1990         reqPut := *req
1991         reqPut.Method = "PUT"
1992         reqPut.URL.Host = colls[0].UUID + ".example"
1993         reqPut.Host = req.URL.Host
1994         reqPut.Body = ioutil.NopCloser(bytes.NewBufferString("testdata"))
1995         resp := httptest.NewRecorder()
1996         s.handler.ServeHTTP(resp, &reqPut)
1997         c.Check(resp.Code, check.Equals, http.StatusCreated)
1998
1999         // new file should not appear in colls[1]
2000         checkWithID(colls[1].PortableDataHash, http.StatusNotFound)
2001         checkWithID(colls[1].UUID, http.StatusNotFound)
2002
2003         checkWithID(colls[0].UUID, http.StatusOK)
2004 }
2005
2006 func copyHeader(h http.Header) http.Header {
2007         hc := http.Header{}
2008         for k, v := range h {
2009                 hc[k] = append([]string(nil), v...)
2010         }
2011         return hc
2012 }
2013
2014 func (s *IntegrationSuite) checkUploadDownloadRequest(c *check.C, req *http.Request,
2015         successCode int, direction string, perm bool, userUuid, collectionUuid, collectionPDH, filepath string) {
2016
2017         client := arvados.NewClientFromEnv()
2018         client.AuthToken = arvadostest.AdminToken
2019         var logentries arvados.LogList
2020         limit1 := 1
2021         err := client.RequestAndDecode(&logentries, "GET", "arvados/v1/logs", nil,
2022                 arvados.ResourceListParams{
2023                         Limit: &limit1,
2024                         Order: "created_at desc"})
2025         c.Check(err, check.IsNil)
2026         c.Check(logentries.Items, check.HasLen, 1)
2027         lastLogId := logentries.Items[0].ID
2028         c.Logf("lastLogId: %d", lastLogId)
2029
2030         var logbuf bytes.Buffer
2031         logger := logrus.New()
2032         logger.Out = &logbuf
2033         resp := httptest.NewRecorder()
2034         req = req.WithContext(ctxlog.Context(context.Background(), logger))
2035         s.handler.ServeHTTP(resp, req)
2036
2037         if perm {
2038                 c.Check(resp.Result().StatusCode, check.Equals, successCode)
2039                 c.Check(logbuf.String(), check.Matches, `(?ms).*msg="File `+direction+`".*`)
2040                 c.Check(logbuf.String(), check.Not(check.Matches), `(?ms).*level=error.*`)
2041
2042                 deadline := time.Now().Add(time.Second)
2043                 for {
2044                         c.Assert(time.Now().After(deadline), check.Equals, false, check.Commentf("timed out waiting for log entry"))
2045                         logentries = arvados.LogList{}
2046                         err = client.RequestAndDecode(&logentries, "GET", "arvados/v1/logs", nil,
2047                                 arvados.ResourceListParams{
2048                                         Filters: []arvados.Filter{
2049                                                 {Attr: "event_type", Operator: "=", Operand: "file_" + direction},
2050                                                 {Attr: "object_uuid", Operator: "=", Operand: userUuid},
2051                                         },
2052                                         Limit: &limit1,
2053                                         Order: "created_at desc",
2054                                 })
2055                         c.Assert(err, check.IsNil)
2056                         if len(logentries.Items) > 0 &&
2057                                 logentries.Items[0].ID > lastLogId &&
2058                                 logentries.Items[0].ObjectUUID == userUuid &&
2059                                 logentries.Items[0].Properties["collection_uuid"] == collectionUuid &&
2060                                 (collectionPDH == "" || logentries.Items[0].Properties["portable_data_hash"] == collectionPDH) &&
2061                                 logentries.Items[0].Properties["collection_file_path"] == filepath {
2062                                 break
2063                         }
2064                         c.Logf("logentries.Items: %+v", logentries.Items)
2065                         time.Sleep(50 * time.Millisecond)
2066                 }
2067         } else {
2068                 c.Check(resp.Result().StatusCode, check.Equals, http.StatusForbidden)
2069                 c.Check(logbuf.String(), check.Equals, "")
2070         }
2071 }
2072
2073 func (s *IntegrationSuite) TestDownloadLoggingPermission(c *check.C) {
2074         u := mustParseURL("http://" + arvadostest.FooCollection + ".keep-web.example/foo")
2075
2076         s.handler.Cluster.Collections.TrustAllContent = true
2077         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(0)
2078
2079         for _, adminperm := range []bool{true, false} {
2080                 for _, userperm := range []bool{true, false} {
2081                         s.handler.Cluster.Collections.WebDAVPermission.Admin.Download = adminperm
2082                         s.handler.Cluster.Collections.WebDAVPermission.User.Download = userperm
2083
2084                         // Test admin permission
2085                         req := &http.Request{
2086                                 Method:     "GET",
2087                                 Host:       u.Host,
2088                                 URL:        u,
2089                                 RequestURI: u.RequestURI(),
2090                                 Header: http.Header{
2091                                         "Authorization": {"Bearer " + arvadostest.AdminToken},
2092                                 },
2093                         }
2094                         s.checkUploadDownloadRequest(c, req, http.StatusOK, "download", adminperm,
2095                                 arvadostest.AdminUserUUID, arvadostest.FooCollection, arvadostest.FooCollectionPDH, "foo")
2096
2097                         // Test user permission
2098                         req = &http.Request{
2099                                 Method:     "GET",
2100                                 Host:       u.Host,
2101                                 URL:        u,
2102                                 RequestURI: u.RequestURI(),
2103                                 Header: http.Header{
2104                                         "Authorization": {"Bearer " + arvadostest.ActiveToken},
2105                                 },
2106                         }
2107                         s.checkUploadDownloadRequest(c, req, http.StatusOK, "download", userperm,
2108                                 arvadostest.ActiveUserUUID, arvadostest.FooCollection, arvadostest.FooCollectionPDH, "foo")
2109                 }
2110         }
2111
2112         s.handler.Cluster.Collections.WebDAVPermission.User.Download = true
2113
2114         for _, tryurl := range []string{"http://" + arvadostest.MultilevelCollection1 + ".keep-web.example/dir1/subdir/file1",
2115                 "http://keep-web/users/active/multilevel_collection_1/dir1/subdir/file1"} {
2116
2117                 u = mustParseURL(tryurl)
2118                 req := &http.Request{
2119                         Method:     "GET",
2120                         Host:       u.Host,
2121                         URL:        u,
2122                         RequestURI: u.RequestURI(),
2123                         Header: http.Header{
2124                                 "Authorization": {"Bearer " + arvadostest.ActiveToken},
2125                         },
2126                 }
2127                 s.checkUploadDownloadRequest(c, req, http.StatusOK, "download", true,
2128                         arvadostest.ActiveUserUUID, arvadostest.MultilevelCollection1, arvadostest.MultilevelCollection1PDH, "dir1/subdir/file1")
2129         }
2130
2131         u = mustParseURL("http://" + strings.Replace(arvadostest.FooCollectionPDH, "+", "-", 1) + ".keep-web.example/foo")
2132         req := &http.Request{
2133                 Method:     "GET",
2134                 Host:       u.Host,
2135                 URL:        u,
2136                 RequestURI: u.RequestURI(),
2137                 Header: http.Header{
2138                         "Authorization": {"Bearer " + arvadostest.ActiveToken},
2139                 },
2140         }
2141         s.checkUploadDownloadRequest(c, req, http.StatusOK, "download", true,
2142                 arvadostest.ActiveUserUUID, "", arvadostest.FooCollectionPDH, "foo")
2143 }
2144
2145 func (s *IntegrationSuite) TestUploadLoggingPermission(c *check.C) {
2146         for _, adminperm := range []bool{true, false} {
2147                 for _, userperm := range []bool{true, false} {
2148
2149                         arv := arvados.NewClientFromEnv()
2150                         arv.AuthToken = arvadostest.ActiveToken
2151
2152                         var coll arvados.Collection
2153                         err := arv.RequestAndDecode(&coll,
2154                                 "POST",
2155                                 "/arvados/v1/collections",
2156                                 nil,
2157                                 map[string]interface{}{
2158                                         "ensure_unique_name": true,
2159                                         "collection": map[string]interface{}{
2160                                                 "name": "test collection",
2161                                         },
2162                                 })
2163                         c.Assert(err, check.Equals, nil)
2164
2165                         u := mustParseURL("http://" + coll.UUID + ".keep-web.example/bar")
2166
2167                         s.handler.Cluster.Collections.WebDAVPermission.Admin.Upload = adminperm
2168                         s.handler.Cluster.Collections.WebDAVPermission.User.Upload = userperm
2169
2170                         // Test admin permission
2171                         req := &http.Request{
2172                                 Method:     "PUT",
2173                                 Host:       u.Host,
2174                                 URL:        u,
2175                                 RequestURI: u.RequestURI(),
2176                                 Header: http.Header{
2177                                         "Authorization": {"Bearer " + arvadostest.AdminToken},
2178                                 },
2179                                 Body: io.NopCloser(bytes.NewReader([]byte("bar"))),
2180                         }
2181                         s.checkUploadDownloadRequest(c, req, http.StatusCreated, "upload", adminperm,
2182                                 arvadostest.AdminUserUUID, coll.UUID, "", "bar")
2183
2184                         // Test user permission
2185                         req = &http.Request{
2186                                 Method:     "PUT",
2187                                 Host:       u.Host,
2188                                 URL:        u,
2189                                 RequestURI: u.RequestURI(),
2190                                 Header: http.Header{
2191                                         "Authorization": {"Bearer " + arvadostest.ActiveToken},
2192                                 },
2193                                 Body: io.NopCloser(bytes.NewReader([]byte("bar"))),
2194                         }
2195                         s.checkUploadDownloadRequest(c, req, http.StatusCreated, "upload", userperm,
2196                                 arvadostest.ActiveUserUUID, coll.UUID, "", "bar")
2197                 }
2198         }
2199 }
2200
2201 func (s *IntegrationSuite) serveAndLogRequests(c *check.C, reqs *map[*http.Request]int) *bytes.Buffer {
2202         logbuf, ctx := newLoggerAndContext()
2203         var wg sync.WaitGroup
2204         for req, expectStatus := range *reqs {
2205                 req := req.WithContext(ctx)
2206                 expectStatus := expectStatus
2207                 wg.Add(1)
2208                 go func() {
2209                         defer wg.Done()
2210                         resp := httptest.NewRecorder()
2211                         s.handler.ServeHTTP(resp, req)
2212                         c.Check(resp.Result().StatusCode, check.Equals, expectStatus)
2213                 }()
2214         }
2215         wg.Wait()
2216         return logbuf
2217 }
2218
2219 func countLogMatches(c *check.C, logbuf *bytes.Buffer, pattern string, matchCount int) bool {
2220         search, err := regexp.Compile(pattern)
2221         if !c.Check(err, check.IsNil, check.Commentf("failed to compile regexp: %v", err)) {
2222                 return false
2223         }
2224         matches := search.FindAll(logbuf.Bytes(), -1)
2225         return c.Check(matches, check.HasLen, matchCount,
2226                 check.Commentf("%d matching log messages: %+v", len(matches), matches))
2227 }
2228
2229 func (s *IntegrationSuite) TestLogThrottling(c *check.C) {
2230         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(time.Hour)
2231         fooURL := "http://" + arvadostest.FooCollection + ".keep-web.example/foo"
2232         req := newRequest("GET", fooURL)
2233         req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
2234         pattern := `\bmsg="File download".* collection_file_path=foo\b`
2235
2236         // All these requests get byte zero and should be logged.
2237         reqs := make(map[*http.Request]int)
2238         reqs[req] = http.StatusOK
2239         for _, byterange := range []string{"0-2", "0-1", "0-", "-3"} {
2240                 req := req.Clone(context.Background())
2241                 req.Header.Set("Range", "bytes="+byterange)
2242                 reqs[req] = http.StatusPartialContent
2243         }
2244         logbuf := s.serveAndLogRequests(c, &reqs)
2245         countLogMatches(c, logbuf, pattern, len(reqs))
2246
2247         // None of these requests get byte zero so they should all be throttled
2248         // (now that we've made at least one request for byte zero).
2249         reqs = make(map[*http.Request]int)
2250         for _, byterange := range []string{"1-2", "1-", "2-", "-1", "-2"} {
2251                 req := req.Clone(context.Background())
2252                 req.Header.Set("Range", "bytes="+byterange)
2253                 reqs[req] = http.StatusPartialContent
2254         }
2255         logbuf = s.serveAndLogRequests(c, &reqs)
2256         countLogMatches(c, logbuf, pattern, 0)
2257 }
2258
2259 func (s *IntegrationSuite) TestLogThrottleInterval(c *check.C) {
2260         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(time.Nanosecond)
2261         logbuf, ctx := newLoggerAndContext()
2262         req := newRequest("GET", "http://"+arvadostest.FooCollection+".keep-web.example/foo")
2263         req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
2264         req = req.WithContext(ctx)
2265
2266         re := regexp.MustCompile(`\bmsg="File download".* collection_file_path=foo\b`)
2267         for expected := 1; expected < 4; expected++ {
2268                 time.Sleep(2 * time.Nanosecond)
2269                 resp := httptest.NewRecorder()
2270                 s.handler.ServeHTTP(resp, req)
2271                 c.Assert(resp.Result().StatusCode, check.Equals, http.StatusOK)
2272                 matches := re.FindAll(logbuf.Bytes(), -1)
2273                 c.Assert(matches, check.HasLen, expected,
2274                         check.Commentf("%d matching log messages: %+v", len(matches), matches))
2275         }
2276 }
2277
2278 func (s *IntegrationSuite) TestLogThrottleDifferentTokens(c *check.C) {
2279         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(time.Hour)
2280         req := newRequest("GET", "http://"+arvadostest.FooCollection+".keep-web.example/foo")
2281         reqs := make(map[*http.Request]int)
2282         for _, token := range []string{arvadostest.ActiveToken, arvadostest.AdminToken} {
2283                 req := req.Clone(context.Background())
2284                 req.Header.Set("Authorization", "Bearer "+token)
2285                 reqs[req] = http.StatusOK
2286         }
2287         logbuf := s.serveAndLogRequests(c, &reqs)
2288         countLogMatches(c, logbuf, `\bmsg="File download".* collection_file_path=foo\b`, len(reqs))
2289 }
2290
2291 func (s *IntegrationSuite) TestLogThrottleDifferentFiles(c *check.C) {
2292         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(time.Hour)
2293         baseURL := "http://" + arvadostest.MultilevelCollection1 + ".keep-web.example/"
2294         reqs := make(map[*http.Request]int)
2295         for _, filename := range []string{"file1", "file2", "file3"} {
2296                 req := newRequest("GET", baseURL+filename)
2297                 req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
2298                 reqs[req] = http.StatusOK
2299         }
2300         logbuf := s.serveAndLogRequests(c, &reqs)
2301         countLogMatches(c, logbuf, `\bmsg="File download".* collection_uuid=`+arvadostest.MultilevelCollection1+`\b`, len(reqs))
2302 }
2303
2304 func (s *IntegrationSuite) TestLogThrottleDifferentSources(c *check.C) {
2305         s.handler.Cluster.Collections.WebDAVLogDownloadInterval = arvados.Duration(time.Hour)
2306         req := newRequest("GET", "http://"+arvadostest.FooCollection+".keep-web.example/foo")
2307         req.Header.Set("Authorization", "Bearer "+arvadostest.ActiveToken)
2308         reqs := make(map[*http.Request]int)
2309         reqs[req] = http.StatusOK
2310         for _, xff := range []string{"10.22.33.44", "100::123"} {
2311                 req := req.Clone(context.Background())
2312                 req.Header.Set("X-Forwarded-For", xff)
2313                 reqs[req] = http.StatusOK
2314         }
2315         logbuf := s.serveAndLogRequests(c, &reqs)
2316         countLogMatches(c, logbuf, `\bmsg="File download".* collection_file_path=foo\b`, len(reqs))
2317 }
2318
2319 func (s *IntegrationSuite) TestConcurrentWrites(c *check.C) {
2320         s.handler.Cluster.Collections.WebDAVCache.TTL = arvados.Duration(time.Second * 2)
2321         client := arvados.NewClientFromEnv()
2322         client.AuthToken = arvadostest.ActiveTokenV2
2323         var handler http.Handler = s.handler
2324         // handler = httpserver.AddRequestIDs(httpserver.LogRequests(s.handler)) // ...to enable request logging in test output
2325
2326         // Each file we upload will consist of some unique content
2327         // followed by 2 MiB of filler content.
2328         filler := "."
2329         for i := 0; i < 21; i++ {
2330                 filler += filler
2331         }
2332
2333         // Start small, and increase concurrency (2^2, 4^2, ...)
2334         // only until hitting failure. Avoids unnecessarily long
2335         // failure reports.
2336         for n := 2; n < 16 && !c.Failed(); n = n * 2 {
2337                 c.Logf("%s: n=%d", c.TestName(), n)
2338
2339                 var coll arvados.Collection
2340                 err := client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, nil)
2341                 c.Assert(err, check.IsNil)
2342                 defer client.RequestAndDecode(&coll, "DELETE", "arvados/v1/collections/"+coll.UUID, nil, nil)
2343
2344                 var wg sync.WaitGroup
2345                 for i := 0; i < n && !c.Failed(); i++ {
2346                         i := i
2347                         wg.Add(1)
2348                         go func() {
2349                                 defer wg.Done()
2350                                 u := mustParseURL(fmt.Sprintf("http://%s.collections.example.com/i=%d", coll.UUID, i))
2351                                 resp := httptest.NewRecorder()
2352                                 req, err := http.NewRequest("MKCOL", u.String(), nil)
2353                                 c.Assert(err, check.IsNil)
2354                                 req.Header.Set("Authorization", "Bearer "+client.AuthToken)
2355                                 handler.ServeHTTP(resp, req)
2356                                 c.Assert(resp.Code, check.Equals, http.StatusCreated)
2357                                 for j := 0; j < n && !c.Failed(); j++ {
2358                                         j := j
2359                                         wg.Add(1)
2360                                         go func() {
2361                                                 defer wg.Done()
2362                                                 content := fmt.Sprintf("i=%d/j=%d", i, j)
2363                                                 u := mustParseURL("http://" + coll.UUID + ".collections.example.com/" + content)
2364
2365                                                 resp := httptest.NewRecorder()
2366                                                 req, err := http.NewRequest("PUT", u.String(), strings.NewReader(content+filler))
2367                                                 c.Assert(err, check.IsNil)
2368                                                 req.Header.Set("Authorization", "Bearer "+client.AuthToken)
2369                                                 handler.ServeHTTP(resp, req)
2370                                                 c.Check(resp.Code, check.Equals, http.StatusCreated)
2371
2372                                                 time.Sleep(time.Second)
2373                                                 resp = httptest.NewRecorder()
2374                                                 req, err = http.NewRequest("GET", u.String(), nil)
2375                                                 c.Assert(err, check.IsNil)
2376                                                 req.Header.Set("Authorization", "Bearer "+client.AuthToken)
2377                                                 handler.ServeHTTP(resp, req)
2378                                                 c.Check(resp.Code, check.Equals, http.StatusOK)
2379                                                 c.Check(strings.TrimSuffix(resp.Body.String(), filler), check.Equals, content)
2380                                         }()
2381                                 }
2382                         }()
2383                 }
2384                 wg.Wait()
2385                 for i := 0; i < n; i++ {
2386                         u := mustParseURL(fmt.Sprintf("http://%s.collections.example.com/i=%d", coll.UUID, i))
2387                         resp := httptest.NewRecorder()
2388                         req, err := http.NewRequest("PROPFIND", u.String(), &bytes.Buffer{})
2389                         c.Assert(err, check.IsNil)
2390                         req.Header.Set("Authorization", "Bearer "+client.AuthToken)
2391                         s.handler.ServeHTTP(resp, req)
2392                         c.Assert(resp.Code, check.Equals, http.StatusMultiStatus)
2393                 }
2394         }
2395 }
2396
2397 func (s *IntegrationSuite) TestDepthHeader(c *check.C) {
2398         s.handler.Cluster.Collections.WebDAVCache.TTL = arvados.Duration(time.Second * 2)
2399         client := arvados.NewClientFromEnv()
2400         client.AuthToken = arvadostest.ActiveTokenV2
2401
2402         var coll arvados.Collection
2403         err := client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", nil, nil)
2404         c.Assert(err, check.IsNil)
2405         defer client.RequestAndDecode(&coll, "DELETE", "arvados/v1/collections/"+coll.UUID, nil, nil)
2406         base := "http://" + coll.UUID + ".collections.example.com/"
2407
2408         for _, trial := range []struct {
2409                 method      string
2410                 path        string
2411                 destination string
2412                 depth       string
2413                 expectCode  int // 0 means expect 2xx
2414         }{
2415                 // setup...
2416                 {method: "MKCOL", path: "dir"},
2417                 {method: "PUT", path: "dir/file"},
2418                 {method: "MKCOL", path: "dir/dir2"},
2419                 // delete with no depth = OK
2420                 {method: "DELETE", path: "dir/dir2", depth: ""},
2421                 // delete with depth other than infinity = fail
2422                 {method: "DELETE", path: "dir", depth: "0", expectCode: 400},
2423                 {method: "DELETE", path: "dir", depth: "1", expectCode: 400},
2424                 // delete with depth infinity = OK
2425                 {method: "DELETE", path: "dir", depth: "infinity"},
2426
2427                 // setup...
2428                 {method: "MKCOL", path: "dir"},
2429                 {method: "PUT", path: "dir/file"},
2430                 {method: "MKCOL", path: "dir/dir2"},
2431                 // move with depth other than infinity = fail
2432                 {method: "MOVE", path: "dir", destination: "moved", depth: "0", expectCode: 400},
2433                 {method: "MOVE", path: "dir", destination: "moved", depth: "1", expectCode: 400},
2434                 // move with depth infinity = OK
2435                 {method: "MOVE", path: "dir", destination: "moved", depth: "infinity"},
2436                 {method: "DELETE", path: "moved"},
2437
2438                 // setup...
2439                 {method: "MKCOL", path: "dir"},
2440                 {method: "PUT", path: "dir/file"},
2441                 {method: "MKCOL", path: "dir/dir2"},
2442                 // copy with depth 0 = create empty destination dir
2443                 {method: "COPY", path: "dir/", destination: "copied-empty/", depth: "0"},
2444                 {method: "DELETE", path: "copied-empty/file", expectCode: 404},
2445                 {method: "DELETE", path: "copied-empty"},
2446                 // copy with depth 0 = create empty destination dir
2447                 // (destination dir has no trailing slash this time)
2448                 {method: "COPY", path: "dir/", destination: "copied-empty-noslash", depth: "0"},
2449                 {method: "DELETE", path: "copied-empty-noslash/file", expectCode: 404},
2450                 {method: "DELETE", path: "copied-empty-noslash"},
2451                 // copy with depth 0 = create empty destination dir
2452                 // (source dir has no trailing slash this time)
2453                 {method: "COPY", path: "dir", destination: "copied-empty-noslash", depth: "0"},
2454                 {method: "DELETE", path: "copied-empty-noslash/file", expectCode: 404},
2455                 {method: "DELETE", path: "copied-empty-noslash"},
2456                 // copy with depth 1 = fail
2457                 {method: "COPY", path: "dir", destination: "copied", depth: "1", expectCode: 400},
2458                 // copy with depth infinity = copy entire subtree
2459                 {method: "COPY", path: "dir/", destination: "copied", depth: "infinity"},
2460                 {method: "DELETE", path: "copied/file"},
2461                 {method: "DELETE", path: "copied"},
2462                 // copy with depth infinity = copy entire subtree
2463                 // (source dir has no trailing slash this time)
2464                 {method: "COPY", path: "dir", destination: "copied", depth: "infinity"},
2465                 {method: "DELETE", path: "copied/file"},
2466                 {method: "DELETE", path: "copied"},
2467                 // cleanup
2468                 {method: "DELETE", path: "dir"},
2469         } {
2470                 c.Logf("trial %+v", trial)
2471                 resp := httptest.NewRecorder()
2472                 req, err := http.NewRequest(trial.method, base+trial.path, strings.NewReader(""))
2473                 c.Assert(err, check.IsNil)
2474                 req.Header.Set("Authorization", "Bearer "+client.AuthToken)
2475                 if trial.destination != "" {
2476                         req.Header.Set("Destination", base+trial.destination)
2477                 }
2478                 if trial.depth != "" {
2479                         req.Header.Set("Depth", trial.depth)
2480                 }
2481                 s.handler.ServeHTTP(resp, req)
2482                 if trial.expectCode != 0 {
2483                         c.Assert(resp.Code, check.Equals, trial.expectCode)
2484                 } else {
2485                         c.Assert(resp.Code >= 200, check.Equals, true, check.Commentf("got code %d", resp.Code))
2486                         c.Assert(resp.Code < 300, check.Equals, true, check.Commentf("got code %d", resp.Code))
2487                 }
2488                 c.Logf("resp.Body: %q", resp.Body.String())
2489         }
2490 }