1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
20 "git.curoverse.com/arvados.git/sdk/go/arvados"
21 "git.curoverse.com/arvados.git/sdk/go/arvadostest"
22 "git.curoverse.com/arvados.git/sdk/go/auth"
23 check "gopkg.in/check.v1"
26 var _ = check.Suite(&UnitSuite{})
28 type UnitSuite struct{}
30 func (s *UnitSuite) TestCORSPreflight(c *check.C) {
31 h := handler{Config: DefaultConfig()}
32 u, _ := url.Parse("http://keep-web.example/c=" + arvadostest.FooCollection + "/foo")
37 RequestURI: u.RequestURI(),
39 "Origin": {"https://workbench.example"},
40 "Access-Control-Request-Method": {"POST"},
44 // Check preflight for an allowed request
45 resp := httptest.NewRecorder()
46 h.ServeHTTP(resp, req)
47 c.Check(resp.Code, check.Equals, http.StatusOK)
48 c.Check(resp.Body.String(), check.Equals, "")
49 c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
50 c.Check(resp.Header().Get("Access-Control-Allow-Methods"), check.Equals, "COPY, DELETE, GET, MKCOL, MOVE, OPTIONS, POST, PROPFIND, PUT, RMCOL")
51 c.Check(resp.Header().Get("Access-Control-Allow-Headers"), check.Equals, "Authorization, Content-Type, Range")
53 // Check preflight for a disallowed request
54 resp = httptest.NewRecorder()
55 req.Header.Set("Access-Control-Request-Method", "MAKE-COFFEE")
56 h.ServeHTTP(resp, req)
57 c.Check(resp.Body.String(), check.Equals, "")
58 c.Check(resp.Code, check.Equals, http.StatusMethodNotAllowed)
61 func (s *UnitSuite) TestInvalidUUID(c *check.C) {
62 bogusID := strings.Replace(arvadostest.FooPdh, "+", "-", 1) + "-"
63 token := arvadostest.ActiveToken
64 for _, trial := range []string{
65 "http://keep-web/c=" + bogusID + "/foo",
66 "http://keep-web/c=" + bogusID + "/t=" + token + "/foo",
67 "http://keep-web/collections/download/" + bogusID + "/" + token + "/foo",
68 "http://keep-web/collections/" + bogusID + "/foo",
69 "http://" + bogusID + ".keep-web/" + bogusID + "/foo",
70 "http://" + bogusID + ".keep-web/t=" + token + "/" + bogusID + "/foo",
73 u, err := url.Parse(trial)
74 c.Assert(err, check.IsNil)
79 RequestURI: u.RequestURI(),
81 resp := httptest.NewRecorder()
82 cfg := DefaultConfig()
83 cfg.AnonymousTokens = []string{arvadostest.AnonymousToken}
84 h := handler{Config: cfg}
85 h.ServeHTTP(resp, req)
86 c.Check(resp.Code, check.Equals, http.StatusNotFound)
90 func mustParseURL(s string) *url.URL {
91 r, err := url.Parse(s)
93 panic("parse URL: " + s)
98 func (s *IntegrationSuite) TestVhost404(c *check.C) {
99 for _, testURL := range []string{
100 arvadostest.NonexistentCollection + ".example.com/theperthcountyconspiracy",
101 arvadostest.NonexistentCollection + ".example.com/t=" + arvadostest.ActiveToken + "/theperthcountyconspiracy",
103 resp := httptest.NewRecorder()
104 u := mustParseURL(testURL)
105 req := &http.Request{
108 RequestURI: u.RequestURI(),
110 s.testServer.Handler.ServeHTTP(resp, req)
111 c.Check(resp.Code, check.Equals, http.StatusNotFound)
112 c.Check(resp.Body.String(), check.Equals, "")
116 // An authorizer modifies an HTTP request to make use of the given
117 // token -- by adding it to a header, cookie, query param, or whatever
118 // -- and returns the HTTP status code we should expect from keep-web if
119 // the token is invalid.
120 type authorizer func(*http.Request, string) int
122 func (s *IntegrationSuite) TestVhostViaAuthzHeader(c *check.C) {
123 s.doVhostRequests(c, authzViaAuthzHeader)
125 func authzViaAuthzHeader(r *http.Request, tok string) int {
126 r.Header.Add("Authorization", "OAuth2 "+tok)
127 return http.StatusUnauthorized
130 func (s *IntegrationSuite) TestVhostViaCookieValue(c *check.C) {
131 s.doVhostRequests(c, authzViaCookieValue)
133 func authzViaCookieValue(r *http.Request, tok string) int {
134 r.AddCookie(&http.Cookie{
135 Name: "arvados_api_token",
136 Value: auth.EncodeTokenCookie([]byte(tok)),
138 return http.StatusUnauthorized
141 func (s *IntegrationSuite) TestVhostViaPath(c *check.C) {
142 s.doVhostRequests(c, authzViaPath)
144 func authzViaPath(r *http.Request, tok string) int {
145 r.URL.Path = "/t=" + tok + r.URL.Path
146 return http.StatusNotFound
149 func (s *IntegrationSuite) TestVhostViaQueryString(c *check.C) {
150 s.doVhostRequests(c, authzViaQueryString)
152 func authzViaQueryString(r *http.Request, tok string) int {
153 r.URL.RawQuery = "api_token=" + tok
154 return http.StatusUnauthorized
157 func (s *IntegrationSuite) TestVhostViaPOST(c *check.C) {
158 s.doVhostRequests(c, authzViaPOST)
160 func authzViaPOST(r *http.Request, tok string) int {
162 r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
163 r.Body = ioutil.NopCloser(strings.NewReader(
164 url.Values{"api_token": {tok}}.Encode()))
165 return http.StatusUnauthorized
168 func (s *IntegrationSuite) TestVhostViaXHRPOST(c *check.C) {
169 s.doVhostRequests(c, authzViaPOST)
171 func authzViaXHRPOST(r *http.Request, tok string) int {
173 r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
174 r.Header.Add("Origin", "https://origin.example")
175 r.Body = ioutil.NopCloser(strings.NewReader(
178 "disposition": {"attachment"},
180 return http.StatusUnauthorized
183 // Try some combinations of {url, token} using the given authorization
184 // mechanism, and verify the result is correct.
185 func (s *IntegrationSuite) doVhostRequests(c *check.C, authz authorizer) {
186 for _, hostPath := range []string{
187 arvadostest.FooCollection + ".example.com/foo",
188 arvadostest.FooCollection + "--collections.example.com/foo",
189 arvadostest.FooCollection + "--collections.example.com/_/foo",
190 arvadostest.FooPdh + ".example.com/foo",
191 strings.Replace(arvadostest.FooPdh, "+", "-", -1) + "--collections.example.com/foo",
192 arvadostest.FooBarDirCollection + ".example.com/dir1/foo",
194 c.Log("doRequests: ", hostPath)
195 s.doVhostRequestsWithHostPath(c, authz, hostPath)
199 func (s *IntegrationSuite) doVhostRequestsWithHostPath(c *check.C, authz authorizer, hostPath string) {
200 for _, tok := range []string{
201 arvadostest.ActiveToken,
202 arvadostest.ActiveToken[:15],
203 arvadostest.SpectatorToken,
207 u := mustParseURL("http://" + hostPath)
208 req := &http.Request{
212 RequestURI: u.RequestURI(),
213 Header: http.Header{},
215 failCode := authz(req, tok)
216 req, resp := s.doReq(req)
217 code, body := resp.Code, resp.Body.String()
219 // If the initial request had a (non-empty) token
220 // showing in the query string, we should have been
221 // redirected in order to hide it in a cookie.
222 c.Check(req.URL.String(), check.Not(check.Matches), `.*api_token=.+`)
224 if tok == arvadostest.ActiveToken {
225 c.Check(code, check.Equals, http.StatusOK)
226 c.Check(body, check.Equals, "foo")
229 c.Check(code >= 400, check.Equals, true)
230 c.Check(code < 500, check.Equals, true)
231 if tok == arvadostest.SpectatorToken {
232 // Valid token never offers to retry
233 // with different credentials.
234 c.Check(code, check.Equals, http.StatusNotFound)
236 // Invalid token can ask to retry
237 // depending on the authz method.
238 c.Check(code, check.Equals, failCode)
240 c.Check(body, check.Equals, "")
245 func (s *IntegrationSuite) doReq(req *http.Request) (*http.Request, *httptest.ResponseRecorder) {
246 resp := httptest.NewRecorder()
247 s.testServer.Handler.ServeHTTP(resp, req)
248 if resp.Code != http.StatusSeeOther {
251 cookies := (&http.Response{Header: resp.Header()}).Cookies()
252 u, _ := req.URL.Parse(resp.Header().Get("Location"))
257 RequestURI: u.RequestURI(),
258 Header: http.Header{},
260 for _, c := range cookies {
266 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToCookie(c *check.C) {
267 s.testVhostRedirectTokenToCookie(c, "GET",
268 arvadostest.FooCollection+".example.com/foo",
269 "?api_token="+arvadostest.ActiveToken,
277 func (s *IntegrationSuite) TestSingleOriginSecretLink(c *check.C) {
278 s.testVhostRedirectTokenToCookie(c, "GET",
279 "example.com/c="+arvadostest.FooCollection+"/t="+arvadostest.ActiveToken+"/foo",
288 // Bad token in URL is 404 Not Found because it doesn't make sense to
289 // retry the same URL with different authorization.
290 func (s *IntegrationSuite) TestSingleOriginSecretLinkBadToken(c *check.C) {
291 s.testVhostRedirectTokenToCookie(c, "GET",
292 "example.com/c="+arvadostest.FooCollection+"/t=bogus/foo",
301 // Bad token in a cookie (even if it got there via our own
302 // query-string-to-cookie redirect) is, in principle, retryable at the
303 // same URL so it's 401 Unauthorized.
304 func (s *IntegrationSuite) TestVhostRedirectQueryTokenToBogusCookie(c *check.C) {
305 s.testVhostRedirectTokenToCookie(c, "GET",
306 arvadostest.FooCollection+".example.com/foo",
307 "?api_token=thisisabogustoken",
310 http.StatusUnauthorized,
315 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSingleOriginError(c *check.C) {
316 s.testVhostRedirectTokenToCookie(c, "GET",
317 "example.com/c="+arvadostest.FooCollection+"/foo",
318 "?api_token="+arvadostest.ActiveToken,
321 http.StatusBadRequest,
326 // If client requests an attachment by putting ?disposition=attachment
327 // in the query string, and gets redirected, the redirect target
328 // should respond with an attachment.
329 func (s *IntegrationSuite) TestVhostRedirectQueryTokenRequestAttachment(c *check.C) {
330 resp := s.testVhostRedirectTokenToCookie(c, "GET",
331 arvadostest.FooCollection+".example.com/foo",
332 "?disposition=attachment&api_token="+arvadostest.ActiveToken,
338 c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
341 func (s *IntegrationSuite) TestVhostRedirectQueryTokenSiteFS(c *check.C) {
342 s.testServer.Config.AttachmentOnlyHost = "download.example.com"
343 resp := s.testVhostRedirectTokenToCookie(c, "GET",
344 "download.example.com/by_id/"+arvadostest.FooCollection+"/foo",
345 "?api_token="+arvadostest.ActiveToken,
351 c.Check(resp.Header().Get("Content-Disposition"), check.Matches, "attachment(;.*)?")
354 func (s *IntegrationSuite) TestVhostRedirectQueryTokenTrustAllContent(c *check.C) {
355 s.testServer.Config.TrustAllContent = true
356 s.testVhostRedirectTokenToCookie(c, "GET",
357 "example.com/c="+arvadostest.FooCollection+"/foo",
358 "?api_token="+arvadostest.ActiveToken,
366 func (s *IntegrationSuite) TestVhostRedirectQueryTokenAttachmentOnlyHost(c *check.C) {
367 s.testServer.Config.AttachmentOnlyHost = "example.com:1234"
369 s.testVhostRedirectTokenToCookie(c, "GET",
370 "example.com/c="+arvadostest.FooCollection+"/foo",
371 "?api_token="+arvadostest.ActiveToken,
374 http.StatusBadRequest,
378 resp := s.testVhostRedirectTokenToCookie(c, "GET",
379 "example.com:1234/c="+arvadostest.FooCollection+"/foo",
380 "?api_token="+arvadostest.ActiveToken,
386 c.Check(resp.Header().Get("Content-Disposition"), check.Equals, "attachment")
389 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie(c *check.C) {
390 s.testVhostRedirectTokenToCookie(c, "POST",
391 arvadostest.FooCollection+".example.com/foo",
393 "application/x-www-form-urlencoded",
394 url.Values{"api_token": {arvadostest.ActiveToken}}.Encode(),
400 func (s *IntegrationSuite) TestVhostRedirectPOSTFormTokenToCookie404(c *check.C) {
401 s.testVhostRedirectTokenToCookie(c, "POST",
402 arvadostest.FooCollection+".example.com/foo",
404 "application/x-www-form-urlencoded",
405 url.Values{"api_token": {arvadostest.SpectatorToken}}.Encode(),
411 func (s *IntegrationSuite) TestAnonymousTokenOK(c *check.C) {
412 s.testServer.Config.AnonymousTokens = []string{arvadostest.AnonymousToken}
413 s.testVhostRedirectTokenToCookie(c, "GET",
414 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
423 func (s *IntegrationSuite) TestAnonymousTokenError(c *check.C) {
424 s.testServer.Config.AnonymousTokens = []string{"anonymousTokenConfiguredButInvalid"}
425 s.testVhostRedirectTokenToCookie(c, "GET",
426 "example.com/c="+arvadostest.HelloWorldCollection+"/Hello%20world.txt",
435 func (s *IntegrationSuite) TestSpecialCharsInPath(c *check.C) {
436 s.testServer.Config.AttachmentOnlyHost = "download.example.com"
438 client := s.testServer.Config.Client
439 client.AuthToken = arvadostest.ActiveToken
440 fs, err := (&arvados.Collection{}).FileSystem(&client, nil)
441 c.Assert(err, check.IsNil)
442 f, err := fs.OpenFile("https:\\\"odd' path chars", os.O_CREATE, 0777)
443 c.Assert(err, check.IsNil)
445 mtxt, err := fs.MarshalManifest(".")
446 c.Assert(err, check.IsNil)
447 coll := arvados.Collection{ManifestText: mtxt}
448 err = client.RequestAndDecode(&coll, "POST", "arvados/v1/collections", client.UpdateBody(coll), nil)
449 c.Assert(err, check.IsNil)
451 u, _ := url.Parse("http://download.example.com/c=" + coll.UUID + "/")
452 req := &http.Request{
456 RequestURI: u.RequestURI(),
458 "Authorization": {"Bearer " + client.AuthToken},
461 resp := httptest.NewRecorder()
462 s.testServer.Handler.ServeHTTP(resp, req)
463 c.Check(resp.Code, check.Equals, http.StatusOK)
464 c.Check(resp.Body.String(), check.Matches, `(?ms).*href="./https:%5c%22odd%27%20path%20chars"\S+https:\\"odd' path chars.*`)
467 // XHRs can't follow redirect-with-cookie so they rely on method=POST
468 // and disposition=attachment (telling us it's acceptable to respond
469 // with content instead of a redirect) and an Origin header that gets
470 // added automatically by the browser (telling us it's desirable to do
472 func (s *IntegrationSuite) TestXHRNoRedirect(c *check.C) {
473 u, _ := url.Parse("http://example.com/c=" + arvadostest.FooCollection + "/foo")
474 req := &http.Request{
478 RequestURI: u.RequestURI(),
480 "Origin": {"https://origin.example"},
481 "Content-Type": {"application/x-www-form-urlencoded"},
483 Body: ioutil.NopCloser(strings.NewReader(url.Values{
484 "api_token": {arvadostest.ActiveToken},
485 "disposition": {"attachment"},
488 resp := httptest.NewRecorder()
489 s.testServer.Handler.ServeHTTP(resp, req)
490 c.Check(resp.Code, check.Equals, http.StatusOK)
491 c.Check(resp.Body.String(), check.Equals, "foo")
492 c.Check(resp.Header().Get("Access-Control-Allow-Origin"), check.Equals, "*")
495 func (s *IntegrationSuite) testVhostRedirectTokenToCookie(c *check.C, method, hostPath, queryString, contentType, reqBody string, expectStatus int, expectRespBody string) *httptest.ResponseRecorder {
496 u, _ := url.Parse(`http://` + hostPath + queryString)
497 req := &http.Request{
501 RequestURI: u.RequestURI(),
502 Header: http.Header{"Content-Type": {contentType}},
503 Body: ioutil.NopCloser(strings.NewReader(reqBody)),
506 resp := httptest.NewRecorder()
508 c.Check(resp.Code, check.Equals, expectStatus)
509 c.Check(resp.Body.String(), check.Equals, expectRespBody)
512 s.testServer.Handler.ServeHTTP(resp, req)
513 if resp.Code != http.StatusSeeOther {
516 c.Check(resp.Body.String(), check.Matches, `.*href="//`+regexp.QuoteMeta(html.EscapeString(hostPath))+`(\?[^"]*)?".*`)
517 cookies := (&http.Response{Header: resp.Header()}).Cookies()
519 u, _ = u.Parse(resp.Header().Get("Location"))
524 RequestURI: u.RequestURI(),
525 Header: http.Header{},
527 for _, c := range cookies {
531 resp = httptest.NewRecorder()
532 s.testServer.Handler.ServeHTTP(resp, req)
533 c.Check(resp.Header().Get("Location"), check.Equals, "")
537 func (s *IntegrationSuite) TestDirectoryListing(c *check.C) {
538 s.testServer.Config.AttachmentOnlyHost = "download.example.com"
539 authHeader := http.Header{
540 "Authorization": {"OAuth2 " + arvadostest.ActiveToken},
542 for _, trial := range []struct {
550 uri: strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/",
552 expect: []string{"dir1/foo", "dir1/bar"},
556 uri: strings.Replace(arvadostest.FooAndBarFilesInDirPDH, "+", "-", -1) + ".example.com/dir1/",
558 expect: []string{"foo", "bar"},
562 uri: "download.example.com/collections/" + arvadostest.FooAndBarFilesInDirUUID + "/",
564 expect: []string{"dir1/foo", "dir1/bar"},
568 uri: "download.example.com/users/active/foo_file_in_dir/",
570 expect: []string{"dir1/"},
574 uri: "download.example.com/users/active/foo_file_in_dir/dir1/",
576 expect: []string{"bar"},
580 uri: "download.example.com/",
582 expect: []string{"users/"},
586 uri: "download.example.com/users",
589 expect: []string{"active/"},
593 uri: "download.example.com/users/",
595 expect: []string{"active/"},
599 uri: "download.example.com/users/active",
601 redirect: "/users/active/",
602 expect: []string{"foo_file_in_dir/"},
606 uri: "download.example.com/users/active/",
608 expect: []string{"foo_file_in_dir/"},
612 uri: "collections.example.com/collections/download/" + arvadostest.FooAndBarFilesInDirUUID + "/" + arvadostest.ActiveToken + "/",
614 expect: []string{"dir1/foo", "dir1/bar"},
618 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken + "/",
620 expect: []string{"dir1/foo", "dir1/bar"},
624 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/t=" + arvadostest.ActiveToken,
626 expect: []string{"dir1/foo", "dir1/bar"},
630 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID,
632 expect: []string{"dir1/foo", "dir1/bar"},
636 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1",
638 redirect: "/c=" + arvadostest.FooAndBarFilesInDirUUID + "/dir1/",
639 expect: []string{"foo", "bar"},
643 uri: "download.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/_/dir1/",
645 expect: []string{"foo", "bar"},
649 uri: arvadostest.FooAndBarFilesInDirUUID + ".example.com/dir1?api_token=" + arvadostest.ActiveToken,
652 expect: []string{"foo", "bar"},
656 uri: "collections.example.com/c=" + arvadostest.FooAndBarFilesInDirUUID + "/theperthcountyconspiracydoesnotexist/",
661 c.Logf("HTML: %q => %q", trial.uri, trial.expect)
662 resp := httptest.NewRecorder()
663 u := mustParseURL("//" + trial.uri)
664 req := &http.Request{
668 RequestURI: u.RequestURI(),
669 Header: copyHeader(trial.header),
671 s.testServer.Handler.ServeHTTP(resp, req)
672 var cookies []*http.Cookie
673 for resp.Code == http.StatusSeeOther {
674 u, _ := req.URL.Parse(resp.Header().Get("Location"))
679 RequestURI: u.RequestURI(),
680 Header: copyHeader(trial.header),
682 cookies = append(cookies, (&http.Response{Header: resp.Header()}).Cookies()...)
683 for _, c := range cookies {
686 resp = httptest.NewRecorder()
687 s.testServer.Handler.ServeHTTP(resp, req)
689 if trial.redirect != "" {
690 c.Check(req.URL.Path, check.Equals, trial.redirect)
692 if trial.expect == nil {
693 c.Check(resp.Code, check.Equals, http.StatusNotFound)
695 c.Check(resp.Code, check.Equals, http.StatusOK)
696 for _, e := range trial.expect {
697 c.Check(resp.Body.String(), check.Matches, `(?ms).*href="./`+e+`".*`)
699 c.Check(resp.Body.String(), check.Matches, `(?ms).*--cut-dirs=`+fmt.Sprintf("%d", trial.cutDirs)+` .*`)
702 c.Logf("WebDAV: %q => %q", trial.uri, trial.expect)
707 RequestURI: u.RequestURI(),
708 Header: copyHeader(trial.header),
709 Body: ioutil.NopCloser(&bytes.Buffer{}),
711 resp = httptest.NewRecorder()
712 s.testServer.Handler.ServeHTTP(resp, req)
713 if trial.expect == nil {
714 c.Check(resp.Code, check.Equals, http.StatusNotFound)
716 c.Check(resp.Code, check.Equals, http.StatusOK)
723 RequestURI: u.RequestURI(),
724 Header: copyHeader(trial.header),
725 Body: ioutil.NopCloser(&bytes.Buffer{}),
727 resp = httptest.NewRecorder()
728 s.testServer.Handler.ServeHTTP(resp, req)
729 if trial.expect == nil {
730 c.Check(resp.Code, check.Equals, http.StatusNotFound)
732 c.Check(resp.Code, check.Equals, http.StatusMultiStatus)
733 for _, e := range trial.expect {
734 c.Check(resp.Body.String(), check.Matches, `(?ms).*<D:href>`+filepath.Join(u.Path, e)+`</D:href>.*`)
740 func (s *IntegrationSuite) TestHealthCheckPing(c *check.C) {
741 s.testServer.Config.ManagementToken = arvadostest.ManagementToken
742 authHeader := http.Header{
743 "Authorization": {"Bearer " + arvadostest.ManagementToken},
746 resp := httptest.NewRecorder()
747 u := mustParseURL("http://download.example.com/_health/ping")
748 req := &http.Request{
752 RequestURI: u.RequestURI(),
755 s.testServer.Handler.ServeHTTP(resp, req)
757 c.Check(resp.Code, check.Equals, http.StatusOK)
758 c.Check(resp.Body.String(), check.Matches, `{"health":"OK"}\n`)
761 func copyHeader(h http.Header) http.Header {
763 for k, v := range h {
764 hc[k] = append([]string(nil), v...)